AIApiController.kt
package org.openapitools.api
import jakarta.validation.Valid
import org.openapitools.entity.UserEntity
import org.openapitools.internal.client.HelpServiceApi
import org.openapitools.internal.client.RecipeServiceApi
import org.openapitools.model.HelpRequest
import org.openapitools.model.HelpResponse
import org.openapitools.model.RecipeInput
import org.openapitools.model.RecipeRequest
import org.openapitools.model.UserPreferences
import org.openapitools.model.UserProfile
import org.openapitools.repository.UserRepository
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.validation.annotation.Validated
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import retrofit2.Response
import tools.jackson.databind.ObjectMapper
import java.io.InterruptedIOException
@RestController
@Validated
@RequestMapping("\${api.base-path:/api/v1}")
class AIApiController(
private val helpServiceApi: HelpServiceApi,
private val recipeServiceApi: RecipeServiceApi,
private val userRepository: UserRepository,
private val objectMapper: ObjectMapper,
) : AIApi {
override fun aiHelpPost(
@Valid helpRequest: HelpRequest,
): ResponseEntity<HelpResponse> {
val user = userRepository.findByUsername(currentUsername()).orElseThrow()
val internalRequest =
org.openapitools.internal.model.HelpRequestForwarded(
profile = user.toInternalProfile(),
prompt = helpRequest.prompt,
recipe = helpRequest.recipe?.toInternalRecipe(),
)
val retrofitResponse =
try {
helpServiceApi.aiHelpPost("", "", internalRequest).execute()
} catch (e: InterruptedIOException) {
// Map the timeout to 504 Gateway Timeout
throw ResponseStatusException(HttpStatus.GATEWAY_TIMEOUT, "Upstream AI service timed out", e)
} catch (e: Exception) {
// Handle other potential network failures
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, e.message ?: "Upstream service unreachable", e)
}
val body = handleRetrofitResponse(retrofitResponse)
return ResponseEntity.ok(HelpResponse(response = body.response))
}
override fun aiRecipesPost(
@Valid recipeRequest: RecipeRequest,
): ResponseEntity<List<RecipeInput>> {
val user = userRepository.findByUsername(currentUsername()).orElseThrow()
// the client sends the active UI language so generated recipes match what the user sees,
// even when no language is stored in their preferences
val profile = user.toInternalProfile()
val profileWithLanguage =
recipeRequest.language?.let { lang ->
profile.copy(
preferences =
profile.preferences.copy(
language =
org.openapitools.internal.model.UserPreferences.Language
.valueOf(lang.name),
),
)
} ?: profile
val internalRequest =
org.openapitools.internal.model.RecipeRequestForwarded(
profile = profileWithLanguage,
prompt = recipeRequest.prompt,
)
val retrofitResponse =
try {
recipeServiceApi.aiRecipesPost("", "", internalRequest).execute()
} catch (e: InterruptedIOException) {
throw ResponseStatusException(HttpStatus.GATEWAY_TIMEOUT, "Upstream AI service timed out", e)
} catch (e: Exception) {
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, e.message ?: "Upstream service unreachable", e)
}
val internalRecipes = handleRetrofitResponse(retrofitResponse)
val publicRecipes = internalRecipes.map { it.toPublicRecipe() }
return ResponseEntity.ok(publicRecipes)
}
override fun aiNutrientsPost(
@Valid nutrientRequest: org.openapitools.model.NutrientRequest,
): ResponseEntity<org.openapitools.model.RecipeNutrients> {
val internalRequest =
org.openapitools.internal.model.NutrientRequestForwarded(
recipe = nutrientRequest.recipe.toInternalRecipe(),
)
val retrofitResponse =
try {
recipeServiceApi.aiNutrientsPost("", "", internalRequest).execute()
} catch (e: InterruptedIOException) {
throw ResponseStatusException(HttpStatus.GATEWAY_TIMEOUT, "Upstream AI service timed out", e)
} catch (e: Exception) {
throw ResponseStatusException(HttpStatus.BAD_GATEWAY, e.message ?: "Upstream service unreachable", e)
}
val internalNutrients = handleRetrofitResponse(retrofitResponse)
val publicNutrients = internalNutrients.toPublicNutrients()
return ResponseEntity.ok(publicNutrients)
}
private fun currentUsername(): String = SecurityContextHolder.getContext().authentication!!.name
/**
* Helper to unwrap Retrofit responses and throw standard Spring Exceptions on failures
*/
private fun <T> handleRetrofitResponse(response: retrofit2.Response<T>): T {
if (!response.isSuccessful) {
val errorBodyString = response.errorBody()?.string()
// Attempt to parse a flat {"message": "..."} OR the fallback nested {"detail": {"message": "..."}}
val extractedMessage =
try {
val jsonNode = objectMapper.readTree(errorBodyString)
// 1. Check if Python sent the new flattened message format
val flatMessage = jsonNode.get("message")?.asText()
if (flatMessage != null) {
flatMessage
} else {
// 2. Fall back to parsing the old/automatic FastAPI detail format
val detailNode = jsonNode.get("detail")
if (detailNode != null && detailNode.isObject) {
detailNode.get("message")?.asText()
} else {
detailNode?.asText()
}
}
} catch (_: Exception) {
null
} ?: "Upstream service returned error: ${response.code()}"
// 2. Resolve the actual HTTP status code dynamically from the upstream response
val upstreamStatus = HttpStatus.resolve(response.code()) ?: HttpStatus.BAD_GATEWAY
// 3. Forward the exact code and message down the pipeline
throw ResponseStatusException(upstreamStatus, extractedMessage)
}
return response.body() ?: throw ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR,
"Empty body",
)
}
// -------------------------------------------------------------------------
// MODEL MAPPING EXTENSIONS (Translates between Public and Internal DTOs)
// -------------------------------------------------------------------------
private fun UserEntity.toInternalProfile(): org.openapitools.internal.model.UserProfile {
val publicPrefs =
preferences?.let {
try {
objectMapper.readValue(it, UserPreferences::class.java)
} catch (_: Exception) {
null
}
} ?: UserPreferences()
return org.openapitools.internal.model.UserProfile(
username = username,
preferences =
org.openapitools.internal.model.UserPreferences(
diet = publicPrefs.diet,
allergies = publicPrefs.allergies,
aboutMe = publicPrefs.aboutMe,
// safely convert between the two different generated enum types using string matching
language =
publicPrefs.language?.name?.let { enumName ->
try {
org.openapitools.internal.model.UserPreferences.Language
.valueOf(enumName)
} catch (_: IllegalArgumentException) {
null // Fallback gracefully if there's an unexpected mismatch
}
},
),
)
}
private fun org.openapitools.model.RecipeInput.toInternalRecipe(): org.openapitools.internal.model.RecipeInput =
org.openapitools.internal.model.RecipeInput(
title = this.title,
portions = this.portions,
instructions = this.instructions,
ingredients =
this.ingredients.map {
org.openapitools.internal.model
.RecipeIngredient(name = it.name, quantity = it.quantity, unit = it.unit)
},
nutrients =
this.nutrients?.let {
org.openapitools.internal.model.RecipeNutrients(
calories = it.calories,
protein = it.protein,
fat = it.fat,
carbs = it.carbs,
)
},
)
private fun org.openapitools.internal.model.RecipeInput.toPublicRecipe(): org.openapitools.model.RecipeInput =
org.openapitools.model.RecipeInput(
title = this.title,
portions = this.portions,
instructions = this.instructions,
ingredients =
this.ingredients.map {
org.openapitools.model.RecipeIngredient(name = it.name, quantity = it.quantity, unit = it.unit)
},
nutrients =
this.nutrients?.let {
org.openapitools.model.RecipeNutrients(calories = it.calories, protein = it.protein, fat = it.fat, carbs = it.carbs)
},
)
private fun org.openapitools.internal.model.RecipeNutrients.toPublicNutrients(): org.openapitools.model.RecipeNutrients =
org.openapitools.model.RecipeNutrients(
calories = this.calories,
protein = this.protein,
fat = this.fat,
carbs = this.carbs,
)
}