diff --git a/build.gradle.kts b/build.gradle.kts index d7fcc6d..dc00826 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -29,9 +29,12 @@ repositories { dependencies { val mysqlConnectorJ = "9.5.0" 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-web") 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.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/HuntController.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/HuntController.kt new file mode 100644 index 0000000..4fc6ae7 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/HuntController.kt @@ -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 { + return ResponseEntity.ok(huntService.getHunt(huntId).toResponse()) + } + + @GetMapping() + fun getAllHunts(@RequestParam status: HuntStatus?): ResponseEntity> { + return ResponseEntity.ok(huntService.getAllHunts(status).map { it.toResponse() }) + } + + @PostMapping() + fun createHunt(@Valid @RequestBody huntRequest: HuntCreateRequest): ResponseEntity { + return ResponseEntity.ok(huntService.createHunt(huntRequest.toDomain()).toResponse()) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/LoginController.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/LoginController.kt index 3d623a7..a021c45 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/LoginController.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/LoginController.kt @@ -2,6 +2,7 @@ package net.halfbinary.scavengerhuntapi.controller import jakarta.servlet.http.Cookie import jakarta.servlet.http.HttpServletResponse +import jakarta.validation.Valid import net.halfbinary.scavengerhuntapi.model.converter.toDomain import net.halfbinary.scavengerhuntapi.model.converter.toLoginResponse import net.halfbinary.scavengerhuntapi.model.request.LoginRequest @@ -17,7 +18,7 @@ import java.net.URLEncoder @RestController class LoginController(private val loginService: LoginService) { @PostMapping("/login") - fun login(@RequestBody body: LoginRequest, response: HttpServletResponse): ResponseEntity { + fun login(@Valid @RequestBody body: LoginRequest, response: HttpServletResponse): ResponseEntity { val result = loginService.login(body.toDomain()) val creds = "${result.email}|${result.name}" val encodedCreds = URLEncoder.encode(creds, "UTF-8") diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/SignupController.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/SignupController.kt index 2fee3f0..44a8a01 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/SignupController.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/SignupController.kt @@ -1,5 +1,6 @@ package net.halfbinary.scavengerhuntapi.controller +import jakarta.validation.Valid import net.halfbinary.scavengerhuntapi.model.converter.toDomain import net.halfbinary.scavengerhuntapi.model.request.HunterSignupRequest import net.halfbinary.scavengerhuntapi.service.SignupService @@ -11,7 +12,7 @@ import org.springframework.web.bind.annotation.RestController @RestController class SignupController(private val signupService: SignupService) { @PostMapping("/signup") - fun hunterSignup(@RequestBody body: HunterSignupRequest): ResponseEntity { + fun hunterSignup(@Valid @RequestBody body: HunterSignupRequest): ResponseEntity { signupService.createNewHunter(body.toDomain()) return ResponseEntity.ok().build() } diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/ExceptionHandler.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/ExceptionHandler.kt index 33883a2..ed93cd5 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/ExceptionHandler.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/ExceptionHandler.kt @@ -2,12 +2,17 @@ package net.halfbinary.scavengerhuntapi.error import net.halfbinary.scavengerhuntapi.error.exception.InvalidEmailException import net.halfbinary.scavengerhuntapi.error.exception.LoginFailedException +import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException import net.halfbinary.scavengerhuntapi.error.exception.PreexistingAccountException 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.ResponseStatus import org.springframework.web.bind.annotation.RestControllerAdvice + @RestControllerAdvice class ExceptionHandler { @@ -28,4 +33,27 @@ class ExceptionHandler { fun invalidEmailException(e: InvalidEmailException): String? { 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 { + return e.bindingResult.allErrors.associate { error -> + Pair( + (error as FieldError).field, + error.defaultMessage + ) + } + } } \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/exception/NotFoundException.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/exception/NotFoundException.kt new file mode 100644 index 0000000..5f309b0 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/exception/NotFoundException.kt @@ -0,0 +1,3 @@ +package net.halfbinary.scavengerhuntapi.error.exception + +class NotFoundException(override val message: String): RuntimeException(message) \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/HuntConverter.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/HuntConverter.kt new file mode 100644 index 0000000..aa3f2df --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/HuntConverter.kt @@ -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) +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/Hunt.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/Hunt.kt new file mode 100644 index 0000000..f0adfd4 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/Hunt.kt @@ -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 +) \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/Hunter.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/Hunter.kt index 787bf84..1fd4559 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/Hunter.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/Hunter.kt @@ -1,9 +1,10 @@ package net.halfbinary.scavengerhuntapi.model.domain -import java.util.UUID +import net.halfbinary.scavengerhuntapi.model.HunterId +import java.util.* data class Hunter( - val id: UUID = UUID.randomUUID(), + val id: HunterId = UUID.randomUUID(), val email: String, val name: String, val password: String, diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/HuntCreateRequest.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/HuntCreateRequest.kt new file mode 100644 index 0000000..bf57a6b --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/HuntCreateRequest.kt @@ -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, +) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/HuntStatus.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/HuntStatus.kt new file mode 100644 index 0000000..bd7db26 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/HuntStatus.kt @@ -0,0 +1,7 @@ +package net.halfbinary.scavengerhuntapi.model.request + +enum class HuntStatus { + UNSTARTED, + ONGOING, + CLOSED +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/HunterSignupRequest.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/HunterSignupRequest.kt index 8e179cc..e7134b4 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/HunterSignupRequest.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/HunterSignupRequest.kt @@ -1,7 +1,14 @@ package net.halfbinary.scavengerhuntapi.model.request +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank + data class HunterSignupRequest( + @field:Email(message = "Must be a valid email address") + @field:NotBlank(message = "Email must not be blank") val email: String, + @field:NotBlank(message = "Name cannot be blank") val name: String, + @field:NotBlank(message = "Password cannot be blank") val password: String ) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/LoginRequest.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/LoginRequest.kt index 4bb018e..37d0827 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/LoginRequest.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/LoginRequest.kt @@ -1,6 +1,10 @@ package net.halfbinary.scavengerhuntapi.model.request +import jakarta.validation.constraints.NotBlank + data class LoginRequest( + @field:NotBlank(message = "Email cannot be blank") val email: String, + @field:NotBlank(message = "Password cannot be blank") val password: String ) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/HuntResponse.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/HuntResponse.kt new file mode 100644 index 0000000..b6c006c --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/HuntResponse.kt @@ -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 +) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/HuntRepository.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/HuntRepository.kt index d2d6b60..a7c8c36 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/HuntRepository.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/HuntRepository.kt @@ -1,9 +1,49 @@ package net.halfbinary.scavengerhuntapi.repository import net.halfbinary.scavengerhuntapi.model.HuntId +import net.halfbinary.scavengerhuntapi.model.HunterId import net.halfbinary.scavengerhuntapi.model.record.HuntRecord import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query import org.springframework.stereotype.Repository @Repository -interface HuntRepository : JpaRepository \ No newline at end of file +interface HuntRepository : JpaRepository { + @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 + + @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 + + @Query(""" + SELECT h.* + FROM hunt h + WHERE h.is_terminated = FALSE + AND h.start_date_time > NOW() + """, nativeQuery = true) + fun findAllUnstarted(): List + + @Query(""" + SELECT h.* + FROM hunt h + WHERE h.is_terminated = TRUE + """, nativeQuery = true) + fun findAllClosed(): List +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/HuntService.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/HuntService.kt new file mode 100644 index 0000000..b90534f --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/HuntService.kt @@ -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 { + 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 { + return huntRepository.findAllOngoingByHunter(hunterId).map { it.toDomain() } + } + + fun createHunt(hunt: Hunt): Hunt { + return huntRepository.save(hunt.toRecord()).toDomain() + } +} \ No newline at end of file