Implements Hunt basic create and get, and adds field validation to controllers

This commit is contained in:
2025-12-22 08:44:14 -06:00
parent 04b61485ea
commit 5905882763
16 changed files with 230 additions and 5 deletions

View File

@@ -29,9 +29,12 @@ repositories {
dependencies { dependencies {
val mysqlConnectorJ = "9.5.0" val mysqlConnectorJ = "9.5.0"
val commonsValidator = "1.10.1" val commonsValidator = "1.10.1"
val jakartaValidation = "3.1.1"
implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("jakarta.validation:jakarta.validation-api:${jakartaValidation}")
implementation("com.mysql:mysql-connector-j:${mysqlConnectorJ}") implementation("com.mysql:mysql-connector-j:${mysqlConnectorJ}")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-reflect")

View File

@@ -0,0 +1,33 @@
package net.halfbinary.scavengerhuntapi.controller
import jakarta.validation.Valid
import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.converter.toDomain
import net.halfbinary.scavengerhuntapi.model.converter.toResponse
import net.halfbinary.scavengerhuntapi.model.request.HuntCreateRequest
import net.halfbinary.scavengerhuntapi.model.request.HuntStatus
import net.halfbinary.scavengerhuntapi.model.response.HuntResponse
import net.halfbinary.scavengerhuntapi.service.HuntService
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("hunt")
class HuntController(private val huntService: HuntService) {
@GetMapping("/{id}")
fun getHunt(@PathVariable("id") huntId: HuntId): ResponseEntity<HuntResponse> {
return ResponseEntity.ok(huntService.getHunt(huntId).toResponse())
}
@GetMapping()
fun getAllHunts(@RequestParam status: HuntStatus?): ResponseEntity<List<HuntResponse>> {
return ResponseEntity.ok(huntService.getAllHunts(status).map { it.toResponse() })
}
@PostMapping()
fun createHunt(@Valid @RequestBody huntRequest: HuntCreateRequest): ResponseEntity<HuntResponse> {
return ResponseEntity.ok(huntService.createHunt(huntRequest.toDomain()).toResponse())
}
}

View File

@@ -2,6 +2,7 @@ package net.halfbinary.scavengerhuntapi.controller
import jakarta.servlet.http.Cookie import jakarta.servlet.http.Cookie
import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletResponse
import jakarta.validation.Valid
import net.halfbinary.scavengerhuntapi.model.converter.toDomain import net.halfbinary.scavengerhuntapi.model.converter.toDomain
import net.halfbinary.scavengerhuntapi.model.converter.toLoginResponse import net.halfbinary.scavengerhuntapi.model.converter.toLoginResponse
import net.halfbinary.scavengerhuntapi.model.request.LoginRequest import net.halfbinary.scavengerhuntapi.model.request.LoginRequest
@@ -17,7 +18,7 @@ import java.net.URLEncoder
@RestController @RestController
class LoginController(private val loginService: LoginService) { class LoginController(private val loginService: LoginService) {
@PostMapping("/login") @PostMapping("/login")
fun login(@RequestBody body: LoginRequest, response: HttpServletResponse): ResponseEntity<LoginResponse> { fun login(@Valid @RequestBody body: LoginRequest, response: HttpServletResponse): ResponseEntity<LoginResponse> {
val result = loginService.login(body.toDomain()) val result = loginService.login(body.toDomain())
val creds = "${result.email}|${result.name}" val creds = "${result.email}|${result.name}"
val encodedCreds = URLEncoder.encode(creds, "UTF-8") val encodedCreds = URLEncoder.encode(creds, "UTF-8")

View File

@@ -1,5 +1,6 @@
package net.halfbinary.scavengerhuntapi.controller package net.halfbinary.scavengerhuntapi.controller
import jakarta.validation.Valid
import net.halfbinary.scavengerhuntapi.model.converter.toDomain import net.halfbinary.scavengerhuntapi.model.converter.toDomain
import net.halfbinary.scavengerhuntapi.model.request.HunterSignupRequest import net.halfbinary.scavengerhuntapi.model.request.HunterSignupRequest
import net.halfbinary.scavengerhuntapi.service.SignupService import net.halfbinary.scavengerhuntapi.service.SignupService
@@ -11,7 +12,7 @@ import org.springframework.web.bind.annotation.RestController
@RestController @RestController
class SignupController(private val signupService: SignupService) { class SignupController(private val signupService: SignupService) {
@PostMapping("/signup") @PostMapping("/signup")
fun hunterSignup(@RequestBody body: HunterSignupRequest): ResponseEntity<Any> { fun hunterSignup(@Valid @RequestBody body: HunterSignupRequest): ResponseEntity<Any> {
signupService.createNewHunter(body.toDomain()) signupService.createNewHunter(body.toDomain())
return ResponseEntity.ok().build() return ResponseEntity.ok().build()
} }

View File

@@ -2,12 +2,17 @@ package net.halfbinary.scavengerhuntapi.error
import net.halfbinary.scavengerhuntapi.error.exception.InvalidEmailException import net.halfbinary.scavengerhuntapi.error.exception.InvalidEmailException
import net.halfbinary.scavengerhuntapi.error.exception.LoginFailedException import net.halfbinary.scavengerhuntapi.error.exception.LoginFailedException
import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException
import net.halfbinary.scavengerhuntapi.error.exception.PreexistingAccountException import net.halfbinary.scavengerhuntapi.error.exception.PreexistingAccountException
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.validation.FieldError
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdvice @RestControllerAdvice
class ExceptionHandler { class ExceptionHandler {
@@ -28,4 +33,27 @@ class ExceptionHandler {
fun invalidEmailException(e: InvalidEmailException): String? { fun invalidEmailException(e: InvalidEmailException): String? {
return e.message return e.message
} }
@ExceptionHandler(NotFoundException::class)
@ResponseStatus(HttpStatus.NOT_FOUND)
fun notFoundException(e: NotFoundException): String? {
return e.message
}
@ExceptionHandler(HttpMessageNotReadableException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun httpMessageNotReadableException(e: HttpMessageNotReadableException): String? {
return e.message
}
@ExceptionHandler(MethodArgumentNotValidException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun handleValidationExceptions(e: MethodArgumentNotValidException): Map<String, String?> {
return e.bindingResult.allErrors.associate { error ->
Pair(
(error as FieldError).field,
error.defaultMessage
)
}
}
} }

View File

@@ -0,0 +1,3 @@
package net.halfbinary.scavengerhuntapi.error.exception
class NotFoundException(override val message: String): RuntimeException(message)

View File

@@ -0,0 +1,22 @@
package net.halfbinary.scavengerhuntapi.model.converter
import net.halfbinary.scavengerhuntapi.model.domain.Hunt
import net.halfbinary.scavengerhuntapi.model.record.HuntRecord
import net.halfbinary.scavengerhuntapi.model.request.HuntCreateRequest
import net.halfbinary.scavengerhuntapi.model.response.HuntResponse
fun HuntRecord.toDomain(): Hunt {
return Hunt(id, title, startDateTime, endDateTime, isTerminated)
}
fun Hunt.toResponse(): HuntResponse {
return HuntResponse(id, title, startDateTime, endDateTime, isTerminated)
}
fun HuntCreateRequest.toDomain(): Hunt {
return Hunt(title = title, startDateTime = startDateTime, endDateTime = endDateTime, isTerminated = false)
}
fun Hunt.toRecord(): HuntRecord {
return HuntRecord(id, title, startDateTime, endDateTime, isTerminated)
}

View File

@@ -0,0 +1,13 @@
package net.halfbinary.scavengerhuntapi.model.domain
import net.halfbinary.scavengerhuntapi.model.HuntId
import java.time.LocalDateTime
import java.util.*
data class Hunt(
val id: HuntId = UUID.randomUUID(),
val title: String,
val startDateTime: LocalDateTime,
val endDateTime: LocalDateTime,
val isTerminated: Boolean
)

View File

@@ -1,9 +1,10 @@
package net.halfbinary.scavengerhuntapi.model.domain package net.halfbinary.scavengerhuntapi.model.domain
import java.util.UUID import net.halfbinary.scavengerhuntapi.model.HunterId
import java.util.*
data class Hunter( data class Hunter(
val id: UUID = UUID.randomUUID(), val id: HunterId = UUID.randomUUID(),
val email: String, val email: String,
val name: String, val name: String,
val password: String, val password: String,

View File

@@ -0,0 +1,14 @@
package net.halfbinary.scavengerhuntapi.model.request
import jakarta.validation.constraints.Future
import jakarta.validation.constraints.NotBlank
import java.time.LocalDateTime
data class HuntCreateRequest(
@field:NotBlank(message = "Hunt title is required")
val title: String,
@field:Future
val startDateTime: LocalDateTime,
@field:Future
val endDateTime: LocalDateTime,
)

View File

@@ -0,0 +1,7 @@
package net.halfbinary.scavengerhuntapi.model.request
enum class HuntStatus {
UNSTARTED,
ONGOING,
CLOSED
}

View File

@@ -1,7 +1,14 @@
package net.halfbinary.scavengerhuntapi.model.request package net.halfbinary.scavengerhuntapi.model.request
import jakarta.validation.constraints.Email
import jakarta.validation.constraints.NotBlank
data class HunterSignupRequest( data class HunterSignupRequest(
@field:Email(message = "Must be a valid email address")
@field:NotBlank(message = "Email must not be blank")
val email: String, val email: String,
@field:NotBlank(message = "Name cannot be blank")
val name: String, val name: String,
@field:NotBlank(message = "Password cannot be blank")
val password: String val password: String
) )

View File

@@ -1,6 +1,10 @@
package net.halfbinary.scavengerhuntapi.model.request package net.halfbinary.scavengerhuntapi.model.request
import jakarta.validation.constraints.NotBlank
data class LoginRequest( data class LoginRequest(
@field:NotBlank(message = "Email cannot be blank")
val email: String, val email: String,
@field:NotBlank(message = "Password cannot be blank")
val password: String val password: String
) )

View File

@@ -0,0 +1,12 @@
package net.halfbinary.scavengerhuntapi.model.response
import net.halfbinary.scavengerhuntapi.model.HuntId
import java.time.LocalDateTime
data class HuntResponse(
val id: HuntId,
val title: String,
val startDateTime: LocalDateTime,
val endDateTime: LocalDateTime,
val isTerminated: Boolean
)

View File

@@ -1,9 +1,49 @@
package net.halfbinary.scavengerhuntapi.repository package net.halfbinary.scavengerhuntapi.repository
import net.halfbinary.scavengerhuntapi.model.HuntId import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.HunterId
import net.halfbinary.scavengerhuntapi.model.record.HuntRecord import net.halfbinary.scavengerhuntapi.model.record.HuntRecord
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@Repository @Repository
interface HuntRepository : JpaRepository<HuntRecord, HuntId> interface HuntRepository : JpaRepository<HuntRecord, HuntId> {
@Query("""
SELECT h.*
FROM hunter u
INNER JOIN hunter_team ht ON u.id = ht.hunter_id
INNER JOIN team t ON ht.team_id = t.id
INNER JOIN team_hunt th ON t.id = th.team_id
INNER JOIN hunt h ON th.hunt_id = h.id
WHERE u.id = :hunterId
AND h.is_terminated = FALSE
AND h.start_date_time < NOW()
AND h.end_date_time > NOW()
""", nativeQuery = true)
fun findAllOngoingByHunter(hunterId: HunterId): List<HuntRecord>
@Query("""
SELECT h.*
FROM hunt h
WHERE h.is_terminated = FALSE
AND h.start_date_time < NOW()
AND h.end_date_time > NOW()
""", nativeQuery = true)
fun findAllOngoing(): List<HuntRecord>
@Query("""
SELECT h.*
FROM hunt h
WHERE h.is_terminated = FALSE
AND h.start_date_time > NOW()
""", nativeQuery = true)
fun findAllUnstarted(): List<HuntRecord>
@Query("""
SELECT h.*
FROM hunt h
WHERE h.is_terminated = TRUE
""", nativeQuery = true)
fun findAllClosed(): List<HuntRecord>
}

View File

@@ -0,0 +1,36 @@
package net.halfbinary.scavengerhuntapi.service
import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException
import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.HunterId
import net.halfbinary.scavengerhuntapi.model.converter.toDomain
import net.halfbinary.scavengerhuntapi.model.converter.toRecord
import net.halfbinary.scavengerhuntapi.model.domain.Hunt
import net.halfbinary.scavengerhuntapi.model.request.HuntStatus
import net.halfbinary.scavengerhuntapi.repository.HuntRepository
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
@Service
class HuntService(private val huntRepository: HuntRepository) {
fun getHunt(huntId: HuntId): Hunt {
return huntRepository.findByIdOrNull(huntId)?.toDomain() ?: throw NotFoundException("No hunt with id ${huntId} found")
}
fun getAllHunts(status: HuntStatus?): List<Hunt> {
return when(status) {
HuntStatus.UNSTARTED -> huntRepository.findAllUnstarted().map { it.toDomain() }
HuntStatus.ONGOING -> huntRepository.findAllOngoing().map { it.toDomain() }
HuntStatus.CLOSED -> huntRepository.findAllClosed().map { it.toDomain() }
else -> huntRepository.findAll().map { it.toDomain() }
}
}
fun getHuntsByHunter(hunterId: HunterId): List<Hunt> {
return huntRepository.findAllOngoingByHunter(hunterId).map { it.toDomain() }
}
fun createHunt(hunt: Hunt): Hunt {
return huntRepository.save(hunt.toRecord()).toDomain()
}
}