Compare commits

6 Commits

Author SHA1 Message Date
04b61485ea Adds login ability, error handling, and logging (#1)
Reviewed-on: #1
Co-authored-by: aarbit <aarbit@gmail.com>
Co-committed-by: aarbit <aarbit@gmail.com>
2025-12-19 05:15:18 +00:00
302feeab1e Adds validation and error handling to signup 2025-12-05 00:15:20 -06:00
8b808bfd34 Adds basic hunter signup endpoint 2025-12-03 21:38:28 -06:00
78489010be Adds open class options for Spring 2025-12-03 21:34:39 -06:00
5563c0c774 Adds records and repositories for DB tables 2025-12-03 16:07:48 -06:00
29802d8b43 Adds database dependencies and connection configuration 2025-12-03 12:44:22 -06:00
34 changed files with 493 additions and 3 deletions

View File

@@ -3,6 +3,7 @@ plugins {
kotlin("plugin.spring") version "2.2.21"
id("org.springframework.boot") version "4.0.0"
id("io.spring.dependency-management") version "1.1.7"
kotlin("plugin.jpa") version "2.2.21"
}
group = "net.halfbinary"
@@ -26,16 +27,19 @@ repositories {
}
dependencies {
val mysqlConnectorJ = "9.5.0"
val commonsValidator = "1.10.1"
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("com.mysql:mysql-connector-j:${mysqlConnectorJ}")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("commons-validator:commons-validator:${commonsValidator}")
developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("com.h2database:h2")
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
testImplementation("org.springframework.boot:spring-boot-starter-actuator-test")
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
@@ -46,6 +50,12 @@ kotlin {
}
}
allOpen {
annotation("jakarta.persistence.Entity")
annotation("jakarta.persistence.MappedSuperclass")
annotation("jakarta.persistence.Embeddable")
}
tasks.withType<Test> {
useJUnitPlatform()
}

View File

@@ -0,0 +1,35 @@
package net.halfbinary.scavengerhuntapi.controller
import jakarta.servlet.http.Cookie
import jakarta.servlet.http.HttpServletResponse
import net.halfbinary.scavengerhuntapi.model.converter.toDomain
import net.halfbinary.scavengerhuntapi.model.converter.toLoginResponse
import net.halfbinary.scavengerhuntapi.model.request.LoginRequest
import net.halfbinary.scavengerhuntapi.model.response.LoginResponse
import net.halfbinary.scavengerhuntapi.service.LoginService
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController
import java.net.URLEncoder
@RestController
class LoginController(private val loginService: LoginService) {
@PostMapping("/login")
fun login(@RequestBody body: LoginRequest, response: HttpServletResponse): ResponseEntity<LoginResponse> {
val result = loginService.login(body.toDomain())
val creds = "${result.email}|${result.name}"
val encodedCreds = URLEncoder.encode(creds, "UTF-8")
response.addCookie(Cookie("creds", encodedCreds))
return ResponseEntity.ok(result.toLoginResponse())
}
@PostMapping("/logout")
fun logout(response: HttpServletResponse): ResponseEntity<String> {
val cookie = Cookie("creds", null)
cookie.maxAge = 0
response.addCookie(cookie)
return ResponseEntity.ok("OK")
}
}

View File

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

View File

@@ -0,0 +1,31 @@
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.PreexistingAccountException
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdvice
class ExceptionHandler {
@ExceptionHandler(PreexistingAccountException::class)
@ResponseStatus(HttpStatus.CONFLICT)
fun preexistingAccountException(e: PreexistingAccountException): String? {
return e.message
}
@ExceptionHandler(LoginFailedException::class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
fun loginFailedException(e: LoginFailedException): String? {
return e.message
}
@ExceptionHandler(InvalidEmailException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun invalidEmailException(e: InvalidEmailException): String? {
return e.message
}
}

View File

@@ -0,0 +1,3 @@
package net.halfbinary.scavengerhuntapi.error.exception
class InvalidEmailException(email: String): RuntimeException("The email ${email} is not valid.")

View File

@@ -0,0 +1,3 @@
package net.halfbinary.scavengerhuntapi.error.exception
class LoginFailedException(): RuntimeException("The email and password combination is not correct.")

View File

@@ -0,0 +1,3 @@
package net.halfbinary.scavengerhuntapi.error.exception
class PreexistingAccountException: RuntimeException("An account with that email already exists.")

View File

@@ -0,0 +1,8 @@
package net.halfbinary.scavengerhuntapi.model
enum class FoundStatus {
SUBMITTED,
APPROVED,
REJECTED,
REMOVED
}

View File

@@ -0,0 +1,9 @@
package net.halfbinary.scavengerhuntapi.model
import java.util.*
typealias FoundId = UUID
typealias HuntId = UUID
typealias HunterId = UUID
typealias ItemId = UUID
typealias TeamId = UUID

View File

@@ -0,0 +1,27 @@
package net.halfbinary.scavengerhuntapi.model.converter
import net.halfbinary.scavengerhuntapi.model.domain.Hunter
import net.halfbinary.scavengerhuntapi.model.record.HunterRecord
import net.halfbinary.scavengerhuntapi.model.request.HunterSignupRequest
import net.halfbinary.scavengerhuntapi.model.response.LoginResponse
fun HunterSignupRequest.toDomain(): Hunter {
return Hunter(
email = email,
name = name,
password = password,
isAdmin = false
)
}
fun Hunter.toRecord(): HunterRecord {
return HunterRecord(id, email, name, password, isAdmin)
}
fun HunterRecord.toDomain(): Hunter {
return Hunter(id, email, name, password, isAdmin)
}
fun Hunter.toLoginResponse(): LoginResponse {
return LoginResponse(email, name)
}

View File

@@ -0,0 +1,8 @@
package net.halfbinary.scavengerhuntapi.model.converter
import net.halfbinary.scavengerhuntapi.model.domain.Login
import net.halfbinary.scavengerhuntapi.model.request.LoginRequest
fun LoginRequest.toDomain(): Login {
return Login(email, password)
}

View File

@@ -0,0 +1,11 @@
package net.halfbinary.scavengerhuntapi.model.domain
import java.util.UUID
data class Hunter(
val id: UUID = UUID.randomUUID(),
val email: String,
val name: String,
val password: String,
val isAdmin: Boolean
)

View File

@@ -0,0 +1,6 @@
package net.halfbinary.scavengerhuntapi.model.domain
data class Login(
val email: String,
val password: String
)

View File

@@ -0,0 +1,23 @@
package net.halfbinary.scavengerhuntapi.model.record
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import net.halfbinary.scavengerhuntapi.model.*
import java.time.LocalDateTime
/**
* Represents a found Item for a Hunt by a Hunter
*/
@Entity
@Table(name = "found")
data class FoundRecord(
@Id
val id: FoundId,
val itemId: ItemId,
val huntId: HuntId,
val hunterId: HunterId,
val foundDateTime: LocalDateTime,
val imageName: String,
val status: FoundStatus
)

View File

@@ -0,0 +1,17 @@
package net.halfbinary.scavengerhuntapi.model.record
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.ItemId
import java.util.*
@Entity
@Table(name = "hunt_item")
data class HuntItemRecord(
@Id
val id: UUID,
val huntId: HuntId,
val itemId: ItemId
)

View File

@@ -0,0 +1,22 @@
package net.halfbinary.scavengerhuntapi.model.record
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import net.halfbinary.scavengerhuntapi.model.HuntId
import java.time.LocalDateTime
/**
* Represents a scavenger hunt event
* @property isTerminated Is the event prematurely stopped
*/
@Entity
@Table(name = "hunt")
data class HuntRecord(
@Id
val id: HuntId,
val title: String,
val startDateTime: LocalDateTime,
val endDateTime: LocalDateTime,
val isTerminated: Boolean
)

View File

@@ -0,0 +1,20 @@
package net.halfbinary.scavengerhuntapi.model.record
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.HunterId
import java.util.*
/**
* Connects a Hunter to a Hunt as a judge
*/
@Entity
@Table(name = "hunter_hunt")
data class HunterHuntRecord(
@Id
val id: UUID,
val hunterId: HunterId,
val huntId: HuntId
)

View File

@@ -0,0 +1,21 @@
package net.halfbinary.scavengerhuntapi.model.record
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import net.halfbinary.scavengerhuntapi.model.HunterId
/**
* Represents a user
* @property isAdmin Is a site administrator
*/
@Entity
@Table(name = "hunter")
data class HunterRecord(
@Id
val id: HunterId,
val email: String,
val name: String,
val password: String,
val isAdmin: Boolean
)

View File

@@ -0,0 +1,17 @@
package net.halfbinary.scavengerhuntapi.model.record
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import net.halfbinary.scavengerhuntapi.model.HunterId
import net.halfbinary.scavengerhuntapi.model.TeamId
import java.util.*
@Entity
@Table(name = "hunter_team")
data class HunterTeamRecord(
@Id
val id: UUID,
val hunterId: HunterId,
val teamId: TeamId
)

View File

@@ -0,0 +1,18 @@
package net.halfbinary.scavengerhuntapi.model.record
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import net.halfbinary.scavengerhuntapi.model.ItemId
/**
* Represents an item to be found on a Hunt
*/
@Entity
@Table(name = "item")
data class ItemRecord(
@Id
val id: ItemId,
val name: String,
val points: Int
)

View File

@@ -0,0 +1,17 @@
package net.halfbinary.scavengerhuntapi.model.record
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.TeamId
import java.util.*
@Entity
@Table(name = "team_hunt")
data class TeamHuntRecord(
@Id
val id: UUID,
val teamId: TeamId,
val huntId: HuntId
)

View File

@@ -0,0 +1,17 @@
package net.halfbinary.scavengerhuntapi.model.record
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import net.halfbinary.scavengerhuntapi.model.TeamId
/**
* Represents a group (1+) of people hunting on a Hunt
*/
@Entity
@Table(name = "team")
data class TeamRecord(
@Id
val id: TeamId,
val name: String,
)

View File

@@ -0,0 +1,7 @@
package net.halfbinary.scavengerhuntapi.model.request
data class HunterSignupRequest(
val email: String,
val name: String,
val password: String
)

View File

@@ -0,0 +1,6 @@
package net.halfbinary.scavengerhuntapi.model.request
data class LoginRequest(
val email: String,
val password: String
)

View File

@@ -0,0 +1,6 @@
package net.halfbinary.scavengerhuntapi.model.response
data class LoginResponse(
val email: String,
val name: String
)

View File

@@ -0,0 +1,9 @@
package net.halfbinary.scavengerhuntapi.repository
import net.halfbinary.scavengerhuntapi.model.FoundId
import net.halfbinary.scavengerhuntapi.model.record.FoundRecord
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface FoundRepository : JpaRepository<FoundRecord, FoundId>

View File

@@ -0,0 +1,9 @@
package net.halfbinary.scavengerhuntapi.repository
import net.halfbinary.scavengerhuntapi.model.HuntId
import net.halfbinary.scavengerhuntapi.model.record.HuntRecord
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface HuntRepository : JpaRepository<HuntRecord, HuntId>

View File

@@ -0,0 +1,20 @@
package net.halfbinary.scavengerhuntapi.repository
import net.halfbinary.scavengerhuntapi.model.HunterId
import net.halfbinary.scavengerhuntapi.model.record.HunterRecord
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
@Repository
interface HunterRepository : JpaRepository<HunterRecord, HunterId> {
fun findByEmail(email: String): HunterRecord?
@Query("""
SELECT h.*
FROM hunter h
WHERE h.email = :email
AND h.password = :password
""", nativeQuery = true)
fun login(email: String, password: String): HunterRecord?
}

View File

@@ -0,0 +1,9 @@
package net.halfbinary.scavengerhuntapi.repository
import net.halfbinary.scavengerhuntapi.model.ItemId
import net.halfbinary.scavengerhuntapi.model.record.ItemRecord
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface ItemRepository : JpaRepository<ItemRecord, ItemId>

View File

@@ -0,0 +1,9 @@
package net.halfbinary.scavengerhuntapi.repository
import net.halfbinary.scavengerhuntapi.model.TeamId
import net.halfbinary.scavengerhuntapi.model.record.TeamRecord
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface TeamRepository : JpaRepository<TeamRecord, TeamId>

View File

@@ -0,0 +1,20 @@
package net.halfbinary.scavengerhuntapi.service
import net.halfbinary.scavengerhuntapi.error.exception.LoginFailedException
import net.halfbinary.scavengerhuntapi.model.converter.toDomain
import net.halfbinary.scavengerhuntapi.model.domain.Hunter
import net.halfbinary.scavengerhuntapi.model.domain.Login
import net.halfbinary.scavengerhuntapi.repository.HunterRepository
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@Service
class LoginService(private val hunterRepository: HunterRepository) {
companion object {
private val log = LoggerFactory.getLogger(LoginService::class.java)
}
fun login(login: Login): Hunter {
log.info("Logging in with email: ${login.email}")
return hunterRepository.login(login.email, login.password)?.toDomain()?:throw LoginFailedException()
}
}

View File

@@ -0,0 +1,31 @@
package net.halfbinary.scavengerhuntapi.service
import net.halfbinary.scavengerhuntapi.error.exception.InvalidEmailException
import net.halfbinary.scavengerhuntapi.error.exception.PreexistingAccountException
import net.halfbinary.scavengerhuntapi.model.converter.toRecord
import net.halfbinary.scavengerhuntapi.model.domain.Hunter
import net.halfbinary.scavengerhuntapi.repository.HunterRepository
import org.apache.commons.validator.routines.EmailValidator
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@Service
class SignupService(private val hunterRepository: HunterRepository) {
companion object {
private val log = LoggerFactory.getLogger(SignupService::class.java)
}
fun createNewHunter(hunter: Hunter) {
log.info("Creating new Hunter with email: ${hunter.email}...")
if (!EmailValidator.getInstance().isValid(hunter.email)) {
log.error("Invalid email ${hunter.email}")
throw InvalidEmailException(hunter.email)
}
if (hunterRepository.findByEmail(hunter.email) != null) {
log.error("Hunter ${hunter.email} already exists")
throw PreexistingAccountException()
}
hunterRepository.save(hunter.toRecord())
log.info("...Created new Hunter with email: ${hunter.email}")
}
}

View File

@@ -1 +1,10 @@
spring.application.name=scavengerhuntapi
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.type.preferred_uuid_jdbc_type=CHAR
spring.datasource.driverClassName=com.mysql.cj.jdbc.Driver
spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USER}
spring.datasource.password=${DB_PASSWORD}

View File

@@ -0,0 +1,11 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<logger name="net.halfbinary.scavengerhuntapi" level="debug" />
<root level="info">
<appender-ref ref="STDOUT" />
</root>
</configuration>