RelationApiController.java

package com.taxonomy.relations.controller;

import com.taxonomy.dto.TaxonomyRelationDto;
import com.taxonomy.model.RelationType;
import com.taxonomy.relations.service.RelationBranchProjectionReadinessService.ReadinessState;
import com.taxonomy.relations.service.RelationProjectionReadService;
import com.taxonomy.relations.service.RelationProjectionReadService.CountResult;
import com.taxonomy.relations.service.RelationProjectionReadService.ReadModel;
import com.taxonomy.relations.service.RelationProjectionReadService.ReadResult;
import com.taxonomy.relations.service.RelationProjectionReadService.RelationProjectionUnavailableException;
import com.taxonomy.workspace.service.RepositoryContext;
import com.taxonomy.workspace.service.WorkspaceResolver;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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 java.util.List;
import java.util.Locale;
import java.util.Map;

@RestController
@RequestMapping("/api")
@Tag(name = "Relations")
public class RelationApiController {

    public static final String READ_MODEL_HEADER =
            "X-Taxonomy-Relation-Read-Model";
    public static final String PROJECTION_STATE_HEADER =
            "X-Taxonomy-Relation-Projection-State";
    public static final String PENDING_RECOVERY_HEADER =
            "X-Taxonomy-Relation-Pending-Recovery";

    private final RelationProjectionReadService relationReadService;
    private final WorkspaceResolver workspaceResolver;

    public RelationApiController(
            RelationProjectionReadService relationReadService,
            WorkspaceResolver workspaceResolver) {
        this.relationReadService = relationReadService;
        this.workspaceResolver = workspaceResolver;
    }

    @Operation(
            summary = "List relations",
            description = "Returns the complete relation projection for the selected repository/workspace, optionally filtered by type")
    @GetMapping("/relations")
    public ResponseEntity<List<TaxonomyRelationDto>> getRelations(
            @Parameter(description = "Filter by relation type")
            @RequestParam(required = false) String type) {
        RepositoryContext context = workspaceResolver
                .resolveCurrentRepositoryContext();
        try {
            ReadResult result;
            if (type != null && !type.isBlank()) {
                RelationType relationType;
                try {
                    relationType = RelationType.valueOf(
                            type.strip().toUpperCase(Locale.ROOT));
                } catch (IllegalArgumentException exception) {
                    return ResponseEntity.badRequest().build();
                }
                result = relationReadService.readByType(context, relationType);
            } else {
                result = relationReadService.readAll(context);
            }
            return readResponse(result);
        } catch (RelationProjectionUnavailableException error) {
            return unavailable(error).build();
        }
    }

    @Operation(
            summary = "Get node relations",
            description = "Returns complete incoming and outgoing projected relations for one node in the selected repository/workspace")
    @GetMapping("/node/{code}/relations")
    public ResponseEntity<List<TaxonomyRelationDto>> getRelationsForNode(
            @PathVariable String code) {
        RepositoryContext context = workspaceResolver
                .resolveCurrentRepositoryContext();
        try {
            return readResponse(
                    relationReadService.readForNode(context, code));
        } catch (IllegalArgumentException error) {
            return ResponseEntity.badRequest().build();
        } catch (RelationProjectionUnavailableException error) {
            return unavailable(error).build();
        }
    }

    /**
     * The historic DB-first write route is deliberately retired. Git-authoritative
     * callers use PUT /api/architecture/relations/{source}/{type}/{target} with
     * If-Match/If-None-Match and Idempotency-Key.
     */
    @Deprecated(forRemoval = true)
    @Operation(
            summary = "Retired DB-first create route",
            description = "Use the Git-authoritative architecture relation command endpoint")
    @PostMapping("/relations")
    public ResponseEntity<TaxonomyRelationDto> createRelation(
            @RequestBody(required = false) Map<String, String> ignored) {
        return ResponseEntity.status(HttpStatus.GONE).build();
    }

    /**
     * Projection row IDs are rebuild-local and cannot be authoritative mutation
     * identities. Call the Git-authoritative identity-based DELETE endpoint.
     */
    @Deprecated(forRemoval = true)
    @Operation(
            summary = "Retired DB-first delete route",
            description = "Use DELETE /api/architecture/relations/{source}/{type}/{target}")
    @DeleteMapping("/relations/{id}")
    public ResponseEntity<Void> deleteRelation(@PathVariable Long id) {
        return ResponseEntity.status(HttpStatus.GONE).build();
    }

    @Operation(
            summary = "Count relations",
            description = "Returns the size of the same complete relation projection used by list and node reads without materializing DTOs")
    @GetMapping("/relations/count")
    public ResponseEntity<Map<String, Long>> countRelations() {
        RepositoryContext context = workspaceResolver
                .resolveCurrentRepositoryContext();
        try {
            CountResult result = relationReadService.count(context);
            return readHeaders(
                    result.readModel(),
                    result.readinessState(),
                    result.authoritativeCommitId())
                    .body(Map.of("count", result.count()));
        } catch (RelationProjectionUnavailableException error) {
            return unavailable(error).build();
        }
    }

    private static ResponseEntity<List<TaxonomyRelationDto>> readResponse(
            ReadResult result) {
        return readHeaders(
                result.readModel(),
                result.readinessState(),
                result.authoritativeCommitId())
                .body(result.relations());
    }

    private static ResponseEntity.BodyBuilder readHeaders(
            ReadModel readModel,
            ReadinessState readinessState,
            String authoritativeCommitId) {
        ResponseEntity.BodyBuilder response = ResponseEntity.ok()
                .header(READ_MODEL_HEADER, readModel.name())
                .header(PROJECTION_STATE_HEADER, readinessState.name())
                .header(HttpHeaders.CACHE_CONTROL, "no-store");
        if (authoritativeCommitId != null) {
            response.header(
                    HttpHeaders.ETAG,
                    GitHttpPrecondition.etag(authoritativeCommitId));
        }
        return response;
    }

    private static ResponseEntity.BodyBuilder unavailable(
            RelationProjectionUnavailableException error) {
        HttpStatus status = error.getReadinessState()
                == ReadinessState.BRANCH_MISSING
                ? HttpStatus.NOT_FOUND
                : HttpStatus.CONFLICT;
        ResponseEntity.BodyBuilder response = ResponseEntity.status(status)
                .header(PROJECTION_STATE_HEADER,
                        error.getReadinessState().name())
                .header(HttpHeaders.CACHE_CONTROL, "no-store");
        if (error.getPendingRecoveryCount() > 0) {
            response.header(
                    PENDING_RECOVERY_HEADER,
                    String.valueOf(error.getPendingRecoveryCount()));
        }
        if (error.getCurrentHeadCommit() != null) {
            response.header(
                    HttpHeaders.ETAG,
                    GitHttpPrecondition.etag(
                            error.getCurrentHeadCommit()));
        }
        return response;
    }
}