GlobalExceptionHandler.kt

package de.werkbaum.api

import de.werkbaum.diff.DiffNotApplicableException
import de.werkbaum.generated.model.ContentConflict
import de.werkbaum.service.ContentConflictException
import de.werkbaum.service.DocumentConflictException
import de.werkbaum.service.DocumentDeletedException
import de.werkbaum.service.DocumentNotFoundException
import de.werkbaum.service.InvalidPatchException
import de.werkbaum.service.StalePatchSequenceException
import de.werkbaum.integration.taiga.TaigaBadRequestException
import de.werkbaum.integration.taiga.TaigaNotConfiguredException
import de.werkbaum.integration.taiga.TaigaUnavailableException
import de.werkbaum.integration.taiga.TaigaUpstreamException
import org.springframework.http.HttpStatus
import org.springframework.http.ProblemDetail
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice

/**
 * Zentrale Fehlerbehandlung im Problem-Details-Format (RFC 9457),
 * passend zum ProblemDetail-Schema der OpenAPI-Spezifikation.
 *
 * Eine Ausnahme davon ist der Überschneidungs-Konflikt des Live-Editings: Er
 * ist kein bloßer Fehlertext, sondern trägt die Daten mit, die der Client zum
 * Weiterarbeiten braucht – und hat deshalb ein eigenes Schema.
 */
@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(DocumentNotFoundException::class)
    fun handleNotFound(ex: DocumentNotFoundException): ProblemDetail =
        ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.message ?: "Nicht gefunden").apply {
            title = "Dokument nicht gefunden"
        }

    @ExceptionHandler(DocumentDeletedException::class)
    fun handleDeleted(ex: DocumentDeletedException): ProblemDetail =
        ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.message ?: "Gelöscht").apply {
            title = "Dokument gelöscht"
        }

    @ExceptionHandler(DocumentConflictException::class)
    fun handleConflict(ex: DocumentConflictException): ProblemDetail =
        ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.message ?: "Konflikt").apply {
            title = "Konflikt"
        }

    /**
     * Echte Überschneidung: 409 mit aktueller Version und dem Diff von der
     * eingereichten Basis dorthin – damit der Client entscheiden kann, ohne
     * neu zu laden.
     */
    @ExceptionHandler(ContentConflictException::class)
    fun handleContentConflict(ex: ContentConflictException): ResponseEntity<ContentConflict> =
        ResponseEntity.status(HttpStatus.CONFLICT).body(
            ContentConflict(
                currentVersion = ex.currentVersion,
                opsSinceBase = ex.opsSinceBase.toApi(),
            )
        )

    /**
     * Nicht anwendbar (422): Prüfsumme, Index oder eine Basisversion, die es
     * nicht mehr gibt. Der Client lädt einmalig neu – das ist der Preis
     * dafür, dass der Text nie kaputtgeht.
     */
    @ExceptionHandler(DiffNotApplicableException::class, StalePatchSequenceException::class)
    fun handleNotApplicable(ex: RuntimeException): ProblemDetail =
        ProblemDetail.forStatusAndDetail(
            HttpStatus.UNPROCESSABLE_ENTITY,
            ex.message ?: "Diff nicht anwendbar",
        ).apply { title = "Diff nicht anwendbar" }

    @ExceptionHandler(InvalidPatchException::class)
    fun handleInvalidPatch(ex: InvalidPatchException): ProblemDetail =
        ProblemDetail.forStatusAndDetail(
            HttpStatus.BAD_REQUEST,
            ex.message ?: "Ungültige Anfrage",
        ).apply { title = "Ungültige Anfrage" }

    /* ---- Taiga-Proxy (D91) ---- */

    /** Kein Ziel konfiguriert: 503 — der Editor fragt vorher `GET /info`. */
    @ExceptionHandler(TaigaNotConfiguredException::class)
    fun handleTaigaNotConfigured(ex: TaigaNotConfiguredException): ProblemDetail =
        ProblemDetail.forStatusAndDetail(
            HttpStatus.SERVICE_UNAVAILABLE,
            ex.message ?: "Taiga nicht konfiguriert",
        ).apply { title = "Taiga nicht konfiguriert" }

    /** Eine Anfrage, die schon der Proxy ablehnt (Refs-Liste der Bulk-Abfrage). */
    @ExceptionHandler(TaigaBadRequestException::class)
    fun handleTaigaBadRequest(ex: TaigaBadRequestException): ProblemDetail =
        ProblemDetail.forStatusAndDetail(
            HttpStatus.BAD_REQUEST,
            ex.message ?: "Ungültige Anfrage",
        ).apply { title = "Ungültige Anfrage" }

    @ExceptionHandler(TaigaUnavailableException::class)
    fun handleTaigaUnavailable(ex: TaigaUnavailableException): ProblemDetail =
        ProblemDetail.forStatusAndDetail(
            HttpStatus.BAD_GATEWAY,
            ex.message ?: "Taiga nicht erreichbar",
        ).apply { title = "Taiga nicht erreichbar" }

    /**
     * Taiga hat mit einem Fehler geantwortet: 4xx wird durchgereicht — Taiga
     * meldet z. B. falsche Zugangsdaten als 400, und der Text hilft dem
     * Benutzer —, ein fremder 5xx wird zu 502.
     */
    @ExceptionHandler(TaigaUpstreamException::class)
    fun handleTaigaUpstream(ex: TaigaUpstreamException): ProblemDetail =
        ProblemDetail.forStatusAndDetail(
            if (ex.status in 400..499) HttpStatus.valueOf(ex.status) else HttpStatus.BAD_GATEWAY,
            ex.message ?: "Taiga-Fehler",
        ).apply { title = "Taiga-Fehler" }
}