RecipesApiController.kt

package org.openapitools.api

import org.openapitools.entity.RecipeEntity
import org.openapitools.model.Recipe
import org.openapitools.model.RecipeCreated
import org.openapitools.model.RecipeIngredient
import org.openapitools.model.RecipeInput
import org.openapitools.model.RecipeNutrients
import org.openapitools.model.RecipeUpdate
import org.openapitools.repository.RecipeRepository
import org.openapitools.repository.UserRepository
import org.slf4j.LoggerFactory
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 tools.jackson.core.type.TypeReference
import tools.jackson.databind.ObjectMapper
import java.time.Instant
import java.time.ZoneOffset

@RestController
@Validated
@RequestMapping("\${api.base-path:/api/v1}")
class RecipesApiController(
	private val recipeRepository: RecipeRepository,
	private val userRepository: UserRepository,
	private val objectMapper: ObjectMapper,
) : RecipesApi {
	private val log = LoggerFactory.getLogger(javaClass)

	override fun recipesGet(): ResponseEntity<List<Recipe>> {
		val user = userRepository.findByUsername(currentUsername()).orElseThrow()
		return ResponseEntity.ok(recipeRepository.findByUserId(user.id).map { it.toApiModel() })
	}

	override fun recipesPost(recipeInput: RecipeInput): ResponseEntity<RecipeCreated> {
		val user = userRepository.findByUsername(currentUsername()).orElseThrow()
		val saved =
			recipeRepository.save(
				RecipeEntity(
					title = recipeInput.title,
					ingredients = objectMapper.writeValueAsString(recipeInput.ingredients),
					instructions = objectMapper.writeValueAsString(recipeInput.instructions),
					portions = java.math.BigDecimal.valueOf(recipeInput.portions),
					nutrientKcal = recipeInput.nutrients?.calories ?: 0,
					nutrientCarb = recipeInput.nutrients?.carbs ?: 0,
					nutrientProt = recipeInput.nutrients?.protein ?: 0,
					nutrientFat = recipeInput.nutrients?.fat ?: 0,
					user = user,
				),
			)
		log.info("Recipe saved [user={}, recipeId={}, title='{}']", user.username, saved.id, saved.title)
		return ResponseEntity.status(HttpStatus.CREATED).body(RecipeCreated(id = saved.id))
	}

	override fun recipesRecipeIdGet(recipeId: Long): ResponseEntity<Recipe> {
		val entity = recipeRepository.findById(recipeId).orElseThrow { NotFoundException("Recipe not found") }
		val user = userRepository.findByUsername(currentUsername()).orElseThrow()
		if (entity.user.id != user.id) throw ForbiddenException("Recipe belongs to a different user")
		entity.openedAt = Instant.now()
		recipeRepository.save(entity)
		return ResponseEntity.ok(entity.toApiModel())
	}

	override fun recipesRecipeIdPut(
		recipeId: Long,
		recipeUpdate: RecipeUpdate,
	): ResponseEntity<Recipe> {
		val entity = recipeRepository.findById(recipeId).orElseThrow { NotFoundException("Recipe not found") }
		val user = userRepository.findByUsername(currentUsername()).orElseThrow()
		if (entity.user.id != user.id) throw ForbiddenException("Recipe belongs to a different user")
		recipeUpdate.title?.let { entity.title = it }
		recipeUpdate.ingredients?.let { entity.ingredients = objectMapper.writeValueAsString(it) }
		recipeUpdate.instructions?.let { entity.instructions = objectMapper.writeValueAsString(it) }
		recipeUpdate.portions?.let { entity.portions = java.math.BigDecimal.valueOf(it) }
		recipeUpdate.nutrients?.let {
			entity.nutrientKcal = it.calories
			entity.nutrientCarb = it.carbs
			entity.nutrientProt = it.protein
			entity.nutrientFat = it.fat
		}
		entity.editedAt = Instant.now()
		log.info("Recipe updated [user={}, recipeId={}]", user.username, recipeId)
		return ResponseEntity.ok(recipeRepository.save(entity).toApiModel())
	}

	override fun recipesRecipeIdDelete(recipeId: Long): ResponseEntity<Unit> {
		val entity = recipeRepository.findById(recipeId).orElseThrow { NotFoundException("Recipe not found") }
		val user = userRepository.findByUsername(currentUsername()).orElseThrow()
		if (entity.user.id != user.id) throw ForbiddenException("Recipe belongs to a different user")
		recipeRepository.delete(entity)
		log.info("Recipe deleted [user={}, recipeId={}]", user.username, recipeId)
		return ResponseEntity(HttpStatus.NO_CONTENT)
	}

	private fun currentUsername(): String = SecurityContextHolder.getContext().authentication!!.name

	private fun RecipeEntity.toApiModel() =
		Recipe(
			id = id,
			title = title,
			ingredients = objectMapper.readValue(ingredients, object : TypeReference<List<RecipeIngredient>>() {}),
			instructions = objectMapper.readValue(instructions, object : TypeReference<List<String>>() {}),
			portions = portions.toDouble(),
			nutrients =
				RecipeNutrients(
					calories = nutrientKcal,
					carbs = nutrientCarb,
					protein = nutrientProt,
					fat = nutrientFat,
				),
			createdAt = createdAt.atOffset(ZoneOffset.UTC),
			editedAt = editedAt.atOffset(ZoneOffset.UTC),
			openedAt = openedAt?.atOffset(ZoneOffset.UTC),
		)
}