ExceptionHandlers.kt

package org.openapitools.api

import jakarta.validation.ConstraintViolationException
import org.openapitools.model.ErrorResponse
import org.slf4j.LoggerFactory
import org.springframework.core.Ordered
import org.springframework.core.annotation.Order
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.HttpStatusCode
import org.springframework.http.ResponseEntity
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.context.request.WebRequest
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
import org.springframework.web.reactive.function.client.WebClientException
import org.springframework.web.reactive.function.client.WebClientResponseException
import org.springframework.web.server.ResponseStatusException
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler

// Additional exception subclasses not generated by the OpenAPI generator
class ForbiddenException(
	msg: String,
) : ApiException(msg, 403)

class ConflictException(
	msg: String,
) : ApiException(msg, 409)

class UnauthorizedException(
	msg: String,
) : ApiException(msg, 401)

class BadGatewayException(
	msg: String,
) : ApiException(msg, 502)

class GatewayTimeoutException(
	msg: String,
) : ApiException(msg, 504)

// Takes priority over the generated DefaultExceptionHandler in Exceptions.kt
@Order(Ordered.HIGHEST_PRECEDENCE)
@ControllerAdvice
class ApiExceptionHandler : ResponseEntityExceptionHandler() {
	private val log = LoggerFactory.getLogger(javaClass)

	@ExceptionHandler(value = [ApiException::class])
	fun onApiException(ex: ApiException): ResponseEntity<ErrorResponse> {
		when {
			ex.code >= 500 -> log.error("Server error [status={}]: {}", ex.code, ex.message)
			ex.code == 401 || ex.code == 403 -> log.warn("Auth error [status={}]: {}", ex.code, ex.message)
		}
		return ResponseEntity.status(ex.code).body(ErrorResponse(ex.message ?: "An error occurred"))
	}

	// Triggered when a ResponseStatusException is thrown in controllers
	@ExceptionHandler(value = [ResponseStatusException::class])
	fun onResponseStatusException(ex: ResponseStatusException): ResponseEntity<ErrorResponse> {
		val errorMessage = ex.reason ?: ex.message
		return ResponseEntity
			.status(ex.statusCode)
			.body(ErrorResponse(message = errorMessage))
	}

	@ExceptionHandler(value = [NotImplementedError::class])
	fun onNotImplemented(ex: NotImplementedError): ResponseEntity<ErrorResponse> {
		log.warn("Not implemented endpoint hit")
		return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).body(ErrorResponse("Not implemented"))
	}

	// Triggered by @Valid on @RequestBody when a field constraint fails
	override fun handleMethodArgumentNotValid(
		ex: MethodArgumentNotValidException,
		headers: HttpHeaders,
		status: HttpStatusCode,
		request: WebRequest,
	): ResponseEntity<Any> {
		val message = ex.bindingResult.fieldErrors.joinToString("; ") { "${it.field}: ${it.defaultMessage}" }
		return ResponseEntity.status(status).body(ErrorResponse(message))
	}

	// Triggered when a path/query variable cannot be converted to the expected type (e.g. non-integer recipeId)
	@ExceptionHandler(value = [MethodArgumentTypeMismatchException::class])
	fun onMethodArgumentTypeMismatch(ex: MethodArgumentTypeMismatchException): ResponseEntity<ErrorResponse> =
		ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ErrorResponse("Invalid value for '${ex.name}': '${ex.value}'"))

	// Triggered by @Validated on path/query parameters
	@ExceptionHandler(value = [ConstraintViolationException::class])
	fun onConstraintViolation(ex: ConstraintViolationException): ResponseEntity<ErrorResponse> {
		val message = ex.constraintViolations.joinToString("; ") { it.message }
		return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ErrorResponse(message))
	}

	// Triggered when the request body is missing or cannot be parsed
	override fun handleHttpMessageNotReadable(
		ex: HttpMessageNotReadableException,
		headers: HttpHeaders,
		status: HttpStatusCode,
		request: WebRequest,
	): ResponseEntity<Any> = ResponseEntity.status(status).body(ErrorResponse("Missing or malformed request body"))

	// Triggered when the AI service is unreachable or returns an HTTP error
	@ExceptionHandler(value = [WebClientException::class])
	fun onWebClientException(ex: WebClientException): ResponseEntity<ErrorResponse> {
		val responseBody = (ex as? WebClientResponseException)?.responseBodyAsString
		if (responseBody != null) {
			log.error("AI service error: {} | body: {}", ex.message, responseBody)
		} else {
			log.error("AI service unavailable: {}", ex.message)
		}
		return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(ErrorResponse("GenAI service unavailable or returned an unparseable response"))
	}

	// Fallback for all other standard Spring MVC exceptions (405, 406, 415, 404, etc.)
	override fun handleExceptionInternal(
		ex: Exception,
		body: Any?,
		headers: HttpHeaders,
		statusCode: HttpStatusCode,
		request: WebRequest,
	): ResponseEntity<Any> {
		if (statusCode.is5xxServerError) log.error("Unhandled error [status={}]", statusCode.value(), ex)
		return ResponseEntity.status(statusCode).headers(headers).body(ErrorResponse(ex.message ?: "An error occurred"))
	}
}