WIP: Finish ktor migration
This commit is contained in:
parent
9fc3d1ac1c
commit
4ef2fed502
43 changed files with 822 additions and 193 deletions
11
Dockerfile
11
Dockerfile
|
@ -1,14 +1,7 @@
|
|||
FROM openjdk:14-jdk as builder
|
||||
MAINTAINER William Brawner <me@wbrawner.com>
|
||||
|
||||
RUN groupadd --system --gid 1000 gradle \
|
||||
&& useradd --system --gid gradle --uid 1000 --shell /bin/bash --create-home gradle
|
||||
|
||||
COPY --chown=gradle:gradle . /home/gradle/src
|
||||
WORKDIR /home/gradle/src
|
||||
RUN /home/gradle/src/gradlew --console=plain --no-daemon bootJar
|
||||
|
||||
FROM adoptopenjdk:openj9
|
||||
EXPOSE 8080
|
||||
COPY --from=builder /home/gradle/src/api/build/libs/api-0.0.1-SNAPSHOT.jar twigs-api.jar
|
||||
CMD /opt/java/openjdk/bin/java $JVM_ARGS -jar /twigs-api.jar
|
||||
COPY app/build/libs/twigs.jar twigs.jar
|
||||
CMD /opt/java/openjdk/bin/java $JVM_ARGS -jar /twigs.jar
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
plugins {
|
||||
kotlin("jvm")
|
||||
kotlin("plugin.serialization") version "1.5.20"
|
||||
`java-library`
|
||||
}
|
||||
|
||||
|
@ -10,6 +11,7 @@ dependencies {
|
|||
api(project(":core"))
|
||||
implementation(project(":storage"))
|
||||
api("io.ktor:ktor-server-core:$ktorVersion")
|
||||
api("io.ktor:ktor-serialization:$ktorVersion")
|
||||
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0")
|
||||
testImplementation("org.junit.jupiter:junit-jupiter-api:5.6.0")
|
||||
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
|
||||
|
|
|
@ -42,11 +42,11 @@ suspend fun PipelineContext<Unit, ApplicationCall>.budgetWithPermission(
|
|||
userId = session.userId,
|
||||
budgetIds = listOf(budgetId)
|
||||
).firstOrNull()
|
||||
if (userPermission?.permission?.isNotAtLeast(permission) != true) {
|
||||
if (userPermission?.permission?.isAtLeast(permission) != true) {
|
||||
errorResponse(HttpStatusCode.Forbidden)
|
||||
return
|
||||
}
|
||||
block(budgetRepository.findAllByIds(listOf(budgetId)).first())
|
||||
block(budgetRepository.findAll(ids = listOf(budgetId)).first())
|
||||
}
|
||||
|
||||
suspend inline fun PipelineContext<Unit, ApplicationCall>.errorResponse(
|
||||
|
|
|
@ -2,14 +2,17 @@ package com.wbrawner.twigs
|
|||
|
||||
import com.wbrawner.twigs.model.Budget
|
||||
import com.wbrawner.twigs.model.UserPermission
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.*
|
||||
|
||||
@Serializable
|
||||
data class BudgetRequest(
|
||||
val name: String? = null,
|
||||
val description: String? = null,
|
||||
val users: Set<UserPermissionRequest>? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BudgetResponse(
|
||||
val id: String,
|
||||
val name: String?,
|
||||
|
@ -28,5 +31,3 @@ data class BudgetResponse(
|
|||
}
|
||||
)
|
||||
}
|
||||
|
||||
data class BudgetBalanceResponse(val id: String, val balance: Long)
|
|
@ -20,10 +20,14 @@ fun Application.budgetRoutes(
|
|||
routing {
|
||||
route("/api/budgets") {
|
||||
authenticate(optional = false) {
|
||||
get("/") {
|
||||
get {
|
||||
val session = call.principal<Session>()!!
|
||||
val budgetIds = permissionRepository.findAll(userId = session.userId).map { it.budgetId }
|
||||
val budgets = budgetRepository.findAllByIds(budgetIds).map {
|
||||
if (budgetIds.isEmpty()) {
|
||||
call.respond(emptyList<BudgetResponse>())
|
||||
return@get
|
||||
}
|
||||
val budgets = budgetRepository.findAll(ids = budgetIds).map {
|
||||
BudgetResponse(it, permissionRepository.findAll(budgetIds = listOf(it.id)))
|
||||
}
|
||||
call.respond(budgets)
|
||||
|
@ -36,7 +40,7 @@ fun Application.budgetRoutes(
|
|||
}
|
||||
}
|
||||
|
||||
post("/") {
|
||||
post {
|
||||
val session = call.principal<Session>()!!
|
||||
val request = call.receive<BudgetRequest>()
|
||||
if (request.name.isNullOrBlank()) {
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
package com.wbrawner.twigs
|
||||
|
||||
import com.wbrawner.twigs.model.Category
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class CategoryRequest(
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
|
@ -11,24 +13,23 @@ data class CategoryRequest(
|
|||
val archived: Boolean? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CategoryResponse(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val description: String?,
|
||||
val amount: Long,
|
||||
val budgetId: String,
|
||||
val isExpense: Boolean,
|
||||
val isArchived: Boolean
|
||||
) {
|
||||
constructor(category: Category) : this(
|
||||
category.id,
|
||||
category.title,
|
||||
category.description,
|
||||
category.amount,
|
||||
category.budgetId!!,
|
||||
category.expense,
|
||||
category.archived
|
||||
)
|
||||
}
|
||||
val expense: Boolean,
|
||||
val archived: Boolean
|
||||
)
|
||||
|
||||
data class CategoryBalanceResponse(val id: String, val balance: Long)
|
||||
fun Category.asResponse(): CategoryResponse = CategoryResponse(
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
amount,
|
||||
budgetId,
|
||||
expense,
|
||||
archived
|
||||
)
|
|
@ -19,29 +19,37 @@ fun Application.categoryRoutes(
|
|||
routing {
|
||||
route("/api/categories") {
|
||||
authenticate(optional = false) {
|
||||
get("/") {
|
||||
get {
|
||||
val session = call.principal<Session>()!!
|
||||
call.respond(categoryRepository.findAll(
|
||||
budgetIds = permissionRepository.findAll(
|
||||
val budgetIds = permissionRepository.findAll(
|
||||
budgetIds = call.request.queryParameters.getAll("budgetIds"),
|
||||
userId = session.userId
|
||||
).map { it.budgetId },
|
||||
).map { it.budgetId }
|
||||
if (budgetIds.isEmpty()) {
|
||||
call.respond(emptyList<CategoryResponse>())
|
||||
return@get
|
||||
}
|
||||
call.respond(categoryRepository.findAll(
|
||||
budgetIds = budgetIds,
|
||||
expense = call.request.queryParameters["expense"]?.toBoolean(),
|
||||
archived = call.request.queryParameters["archived"]?.toBoolean()
|
||||
).map { CategoryResponse(it) })
|
||||
).map { it.asResponse() })
|
||||
}
|
||||
|
||||
get("/{id}") {
|
||||
val session = call.principal<Session>()!!
|
||||
val budgetIds = permissionRepository.findAll(userId = session.userId).map { it.budgetId }
|
||||
if (budgetIds.isEmpty()) {
|
||||
errorResponse()
|
||||
return@get
|
||||
}
|
||||
call.respond(categoryRepository.findAll(
|
||||
ids = call.parameters.getAll("id"),
|
||||
budgetIds = permissionRepository.findAll(
|
||||
userId = session.userId
|
||||
).map { it.budgetId }
|
||||
).map { CategoryResponse(it) })
|
||||
budgetIds = budgetIds
|
||||
).map { it.asResponse() })
|
||||
}
|
||||
|
||||
post("/") {
|
||||
post {
|
||||
val session = call.principal<Session>()!!
|
||||
val request = call.receive<CategoryRequest>()
|
||||
if (request.title.isNullOrBlank()) {
|
||||
|
@ -61,16 +69,15 @@ fun Application.categoryRoutes(
|
|||
return@post
|
||||
}
|
||||
call.respond(
|
||||
CategoryResponse(
|
||||
categoryRepository.save(
|
||||
Category(
|
||||
title = request.title,
|
||||
description = request.description,
|
||||
amount = request.amount ?: 0L,
|
||||
expense = request.expense ?: true,
|
||||
budgetId = request.budgetId
|
||||
)
|
||||
)
|
||||
)
|
||||
).asResponse()
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -86,23 +93,21 @@ fun Application.categoryRoutes(
|
|||
requireBudgetWithPermission(
|
||||
permissionRepository,
|
||||
session.userId,
|
||||
category.budgetId!!,
|
||||
category.budgetId,
|
||||
Permission.WRITE
|
||||
) {
|
||||
return@put
|
||||
}
|
||||
call.respond(
|
||||
CategoryResponse(
|
||||
categoryRepository.save(
|
||||
Category(
|
||||
category.copy(
|
||||
title = request.title ?: category.title,
|
||||
description = request.description ?: category.description,
|
||||
amount = request.amount ?: category.amount,
|
||||
expense = request.expense ?: category.expense,
|
||||
archived = request.archived ?: category.archived
|
||||
)
|
||||
)
|
||||
archived = request.archived ?: category.archived,
|
||||
)
|
||||
).asResponse()
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -117,7 +122,7 @@ fun Application.categoryRoutes(
|
|||
requireBudgetWithPermission(
|
||||
permissionRepository,
|
||||
session.userId,
|
||||
category.budgetId!!,
|
||||
category.budgetId,
|
||||
Permission.WRITE
|
||||
) {
|
||||
return@delete
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
package com.wbrawner.twigs
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ErrorResponse(val message: String)
|
|
@ -1,7 +1,9 @@
|
|||
package com.wbrawner.twigs
|
||||
|
||||
import com.wbrawner.twigs.model.Transaction
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class TransactionRequest(
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
|
@ -12,6 +14,7 @@ data class TransactionRequest(
|
|||
val budgetId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TransactionResponse(
|
||||
val id: String,
|
||||
val title: String?,
|
||||
|
@ -24,6 +27,7 @@ data class TransactionResponse(
|
|||
val createdBy: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BalanceResponse(val balance: Long)
|
||||
|
||||
fun Transaction.asResponse(): TransactionResponse = TransactionResponse(
|
||||
|
|
|
@ -20,9 +20,10 @@ fun Application.transactionRoutes(
|
|||
routing {
|
||||
route("/api/transactions") {
|
||||
authenticate(optional = false) {
|
||||
get("/") {
|
||||
get {
|
||||
val session = call.principal<Session>()!!
|
||||
call.respond(transactionRepository.findAll(
|
||||
call.respond(
|
||||
transactionRepository.findAll(
|
||||
budgetIds = permissionRepository.findAll(
|
||||
budgetIds = call.request.queryParameters.getAll("budgetIds"),
|
||||
userId = session.userId
|
||||
|
@ -53,11 +54,14 @@ fun Application.transactionRoutes(
|
|||
get("/sum") {
|
||||
val categoryId = call.request.queryParameters["categoryId"]
|
||||
val budgetId = call.request.queryParameters["budgetId"]
|
||||
val from = call.request.queryParameters["from"]?.toInstant() ?: firstOfMonth.toInstant()
|
||||
val to = call.request.queryParameters["to"]?.toInstant() ?: endOfMonth.toInstant()
|
||||
val from = call.request.queryParameters["from"]?.toInstant() ?: firstOfMonth
|
||||
val to = call.request.queryParameters["to"]?.toInstant() ?: endOfMonth
|
||||
val balance = if (!categoryId.isNullOrBlank()) {
|
||||
if (!budgetId.isNullOrBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "budgetId and categoryId cannot be provided together")
|
||||
errorResponse(
|
||||
HttpStatusCode.BadRequest,
|
||||
"budgetId and categoryId cannot be provided together"
|
||||
)
|
||||
return@get
|
||||
}
|
||||
transactionRepository.sumByCategory(categoryId, from, to)
|
||||
|
@ -70,7 +74,7 @@ fun Application.transactionRoutes(
|
|||
call.respond(BalanceResponse(balance))
|
||||
}
|
||||
|
||||
post("/") {
|
||||
post {
|
||||
val session = call.principal<Session>()!!
|
||||
val request = call.receive<TransactionRequest>()
|
||||
if (request.title.isNullOrBlank()) {
|
||||
|
|
|
@ -3,26 +3,33 @@ package com.wbrawner.twigs
|
|||
import com.wbrawner.twigs.model.Permission
|
||||
import com.wbrawner.twigs.model.User
|
||||
import com.wbrawner.twigs.storage.Session
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.*
|
||||
|
||||
@Serializable
|
||||
data class UserRequest(
|
||||
val username: String? = null,
|
||||
val password: String? = null,
|
||||
val email: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoginRequest(val username: String, val password: String)
|
||||
|
||||
@Serializable
|
||||
data class UserResponse(val id: String, val username: String, val email: String?)
|
||||
|
||||
@Serializable
|
||||
data class UserPermissionRequest(
|
||||
val user: String,
|
||||
val permission: Permission = Permission.READ
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserPermissionResponse(val user: String, val permission: Permission?)
|
||||
|
||||
data class SessionResponse(val token: String, val expiration: String)
|
||||
@Serializable
|
||||
data class SessionResponse(val userId: String, val token: String, val expiration: String)
|
||||
|
||||
data class PasswordResetRequest(
|
||||
val userId: Long,
|
||||
|
@ -33,4 +40,4 @@ data class PasswordResetRequest(
|
|||
|
||||
fun User.asResponse(): UserResponse = UserResponse(id, name, email)
|
||||
|
||||
fun Session.asResponse(): SessionResponse = SessionResponse(token, expiration.toInstant().toString())
|
||||
fun Session.asResponse(): SessionResponse = SessionResponse(userId, token, expiration.toString())
|
|
@ -11,7 +11,6 @@ import io.ktor.http.*
|
|||
import io.ktor.request.*
|
||||
import io.ktor.response.*
|
||||
import io.ktor.routing.*
|
||||
import io.ktor.sessions.*
|
||||
|
||||
fun Application.userRoutes(
|
||||
permissionRepository: PermissionRepository,
|
||||
|
@ -23,15 +22,15 @@ fun Application.userRoutes(
|
|||
post("/login") {
|
||||
val request = call.receive<LoginRequest>()
|
||||
val user =
|
||||
userRepository.find(name = request.username, password = request.password.hash()).firstOrNull()
|
||||
?: userRepository.find(email = request.username, password = request.password.hash())
|
||||
userRepository.findAll(nameOrEmail = request.username, password = request.password.hash())
|
||||
.firstOrNull()
|
||||
?: userRepository.findAll(nameOrEmail = request.username, password = request.password.hash())
|
||||
.firstOrNull()
|
||||
?: run {
|
||||
errorResponse(HttpStatusCode.Unauthorized, "Invalid credentials")
|
||||
return@post
|
||||
}
|
||||
val session = sessionRepository.save(Session(userId = user.id))
|
||||
call.sessions.set(session)
|
||||
call.respond(session.asResponse())
|
||||
}
|
||||
|
||||
|
@ -49,7 +48,7 @@ fun Application.userRoutes(
|
|||
userRepository.save(
|
||||
User(
|
||||
name = request.username,
|
||||
password = request.password,
|
||||
password = request.password.hash(),
|
||||
email = request.email
|
||||
)
|
||||
).asResponse()
|
||||
|
@ -57,10 +56,10 @@ fun Application.userRoutes(
|
|||
}
|
||||
|
||||
authenticate(optional = false) {
|
||||
get("/") {
|
||||
get {
|
||||
val query = call.request.queryParameters.getAll("query")
|
||||
if (query?.firstOrNull()?.isNotBlank() == true) {
|
||||
call.respond(userRepository.findAll(nameLike = query.first()).map{ it.asResponse() })
|
||||
call.respond(userRepository.findAll(nameLike = query.first()).map { it.asResponse() })
|
||||
return@get
|
||||
}
|
||||
permissionRepository.findAll(
|
||||
|
@ -76,10 +75,11 @@ fun Application.userRoutes(
|
|||
userRepository.findAll(ids = call.parameters.getAll("id"))
|
||||
.firstOrNull()
|
||||
?.asResponse()
|
||||
?.let { call.respond(it) }
|
||||
?: errorResponse(HttpStatusCode.NotFound)
|
||||
}
|
||||
|
||||
post("/") {
|
||||
post {
|
||||
val request = call.receive<UserRequest>()
|
||||
if (request.username.isNullOrBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "Username must not be null or blank")
|
||||
|
@ -126,11 +126,16 @@ fun Application.userRoutes(
|
|||
delete("/{id}") {
|
||||
val session = call.principal<Session>()!!
|
||||
// TODO: Add some kind of admin denotation to allow admins to delete other users
|
||||
if (call.parameters["id"] != session.userId) {
|
||||
val user = userRepository.findAll(call.parameters.getAll("íd")!!).firstOrNull()
|
||||
if (user == null) {
|
||||
errorResponse()
|
||||
return@delete
|
||||
}
|
||||
if (user.id != session.userId) {
|
||||
errorResponse(HttpStatusCode.Forbidden)
|
||||
return@delete
|
||||
}
|
||||
userRepository.deleteById(session.userId)
|
||||
userRepository.delete(user)
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,12 +3,10 @@ import java.net.URI
|
|||
plugins {
|
||||
java
|
||||
kotlin("jvm")
|
||||
id("org.springframework.boot")
|
||||
application
|
||||
id("com.github.johnrengelman.shadow") version "7.0.0"
|
||||
}
|
||||
|
||||
apply(plugin = "io.spring.dependency-management")
|
||||
|
||||
repositories {
|
||||
mavenLocal()
|
||||
mavenCentral()
|
||||
|
@ -23,7 +21,7 @@ val ktorVersion: String by rootProject.extra
|
|||
dependencies {
|
||||
implementation(project(":api"))
|
||||
implementation(project(":core"))
|
||||
implementation(project(":storage"))
|
||||
implementation(project(":db"))
|
||||
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion")
|
||||
implementation("io.ktor:ktor-server-core:$ktorVersion")
|
||||
|
@ -31,27 +29,21 @@ dependencies {
|
|||
implementation("io.ktor:ktor-server-sessions:$ktorVersion")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0")
|
||||
implementation("ch.qos.logback:logback-classic:1.2.3")
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||
implementation("org.springframework.session:spring-session-jdbc")
|
||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||
runtimeOnly("mysql:mysql-connector-java:8.0.15")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testImplementation("org.springframework.security:spring-security-test:5.1.5.RELEASE")
|
||||
}
|
||||
|
||||
description = "twigs-server"
|
||||
|
||||
val twigsMain = "com.wbrawner.twigs.server.TwigsServerApplication"
|
||||
val twigsMain = "com.wbrawner.twigs.server.ApplicationKt"
|
||||
|
||||
application {
|
||||
mainClass.set(twigsMain)
|
||||
}
|
||||
|
||||
tasks.bootJar {
|
||||
mainClassName = twigsMain
|
||||
}
|
||||
|
||||
tasks.bootRun {
|
||||
mainClass.set(twigsMain)
|
||||
tasks.shadowJar {
|
||||
manifest {
|
||||
attributes("Main-Class" to twigsMain)
|
||||
archiveBaseName.set("twigs")
|
||||
archiveClassifier.set("")
|
||||
archiveVersion.set("")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,16 @@
|
|||
package com.wbrawner.twigs.server
|
||||
|
||||
import com.wbrawner.twigs.*
|
||||
import com.wbrawner.twigs.model.Transaction
|
||||
import com.wbrawner.twigs.db.*
|
||||
import com.wbrawner.twigs.storage.*
|
||||
import com.zaxxer.hikari.HikariConfig
|
||||
import com.zaxxer.hikari.HikariDataSource
|
||||
import io.ktor.application.*
|
||||
import io.ktor.auth.*
|
||||
import io.ktor.features.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.response.*
|
||||
import io.ktor.serialization.*
|
||||
import io.ktor.sessions.*
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
|
@ -15,8 +21,36 @@ import kotlin.time.ExperimentalTime
|
|||
|
||||
fun main(args: Array<String>): Unit = io.ktor.server.cio.EngineMain.main(args)
|
||||
|
||||
private const val DATABASE_VERSION = 1
|
||||
|
||||
@ExperimentalTime
|
||||
fun Application.module(
|
||||
fun Application.module() {
|
||||
val dbHost = environment.config.propertyOrNull("ktor.database.host")?.getString() ?: "localhost"
|
||||
val dbPort = environment.config.propertyOrNull("ktor.database.port")?.getString() ?: "5432"
|
||||
val dbName = environment.config.propertyOrNull("ktor.database.name")?.getString() ?: "twigs"
|
||||
val dbUser = environment.config.propertyOrNull("ktor.database.user")?.getString() ?: "twigs"
|
||||
val dbPass = environment.config.propertyOrNull("ktor.database.password")?.getString() ?: "twigs"
|
||||
val jdbcUrl = "jdbc:postgresql://$dbHost:$dbPort/$dbName?stringtype=unspecified"
|
||||
HikariDataSource(HikariConfig().apply {
|
||||
setJdbcUrl(jdbcUrl)
|
||||
username = dbUser
|
||||
password = dbPass
|
||||
}).also {
|
||||
moduleWithDependencies(
|
||||
metadataRepository = MetadataRepository(it),
|
||||
budgetRepository = JdbcBudgetRepository(it),
|
||||
categoryRepository = JdbcCategoryRepository(it),
|
||||
permissionRepository = JdbcPermissionRepository(it),
|
||||
sessionRepository = JdbcSessionRepository(it),
|
||||
transactionRepository = JdbcTransactionRepository(it),
|
||||
userRepository = JdbcUserRepository(it)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalTime
|
||||
fun Application.moduleWithDependencies(
|
||||
metadataRepository: MetadataRepository,
|
||||
budgetRepository: BudgetRepository,
|
||||
categoryRepository: CategoryRepository,
|
||||
permissionRepository: PermissionRepository,
|
||||
|
@ -24,27 +58,57 @@ fun Application.module(
|
|||
transactionRepository: TransactionRepository,
|
||||
userRepository: UserRepository
|
||||
) {
|
||||
install(Sessions) {
|
||||
header<String>("Authorization")
|
||||
}
|
||||
install(CallLogging)
|
||||
install(Authentication) {
|
||||
session<String> {
|
||||
validate { token ->
|
||||
val session = sessionRepository.findAll(token).firstOrNull()
|
||||
?: return@validate null
|
||||
return@validate if (twoWeeksFromNow.after(session.expiration)) {
|
||||
session
|
||||
session<Session> {
|
||||
challenge {
|
||||
call.respond(HttpStatusCode.Unauthorized)
|
||||
}
|
||||
validate { session ->
|
||||
environment.log.info("Validating session")
|
||||
val storedSession = sessionRepository.findAll(session.token)
|
||||
.firstOrNull()
|
||||
if (storedSession == null) {
|
||||
environment.log.info("Did not find session!")
|
||||
return@validate null
|
||||
} else {
|
||||
environment.log.info("Found session!")
|
||||
}
|
||||
return@validate if (twoWeeksFromNow.isAfter(storedSession.expiration)) {
|
||||
sessionRepository.save(storedSession.copy(expiration = twoWeeksFromNow))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
install(Sessions) {
|
||||
header<Session>("Authorization") {
|
||||
serializer = object : SessionSerializer<Session> {
|
||||
override fun deserialize(text: String): Session {
|
||||
environment.log.info("Deserializing session!")
|
||||
return Session(token = text.substringAfter("Bearer "))
|
||||
}
|
||||
|
||||
override fun serialize(session: Session): String = session.token
|
||||
}
|
||||
}
|
||||
}
|
||||
install(ContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
budgetRoutes(budgetRepository, permissionRepository)
|
||||
categoryRoutes(categoryRepository, permissionRepository)
|
||||
transactionRoutes(transactionRepository, permissionRepository)
|
||||
userRoutes(permissionRepository, sessionRepository, userRepository)
|
||||
launch {
|
||||
val metadata = (metadataRepository.findAll().firstOrNull() ?: DatabaseMetadata())
|
||||
var version = metadata.version
|
||||
while (currentCoroutineContext().isActive && version++ < DATABASE_VERSION) {
|
||||
metadataRepository.runMigration(version)
|
||||
metadataRepository.save(metadata.copy(version = version))
|
||||
}
|
||||
salt = metadata.salt
|
||||
while (currentCoroutineContext().isActive) {
|
||||
delay(Duration.hours(24))
|
||||
sessionRepository.deleteExpired()
|
||||
|
|
|
@ -11,6 +11,8 @@ ktor {
|
|||
host = ${?TWIGS_DB_HOST}
|
||||
port = 5432
|
||||
port = ${?TWIGS_DB_PORT}
|
||||
name = twigs
|
||||
name = ${?TWIGS_DB_NAME}
|
||||
user = twigs
|
||||
user = ${?TWIGS_DB_USER}
|
||||
password = twigs
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
spring.jpa.hibernate.ddl-auto=none
|
||||
spring.datasource.url=jdbc:mysql://localhost:3306/budget
|
||||
spring.datasource.username=budget
|
||||
spring.datasource.password=budget
|
||||
spring.profiles.active=prod
|
||||
spring.session.jdbc.initialize-schema=always
|
||||
spring.datasource.testWhileIdle=true
|
||||
spring.datasource.timeBetweenEvictionRunsMillis=60000
|
||||
spring.datasource.validationQuery=SELECT 1
|
||||
twigs.cors.domains=*
|
|
@ -7,13 +7,10 @@ buildscript {
|
|||
repositories {
|
||||
mavenLocal()
|
||||
mavenCentral()
|
||||
maven { url = java.net.URI("https://repo.spring.io/snapshot") }
|
||||
maven { url = java.net.URI("https://repo.spring.io/milestone") }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
|
||||
classpath("org.springframework.boot:spring-boot-gradle-plugin:2.2.4.RELEASE")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -33,6 +30,6 @@ allprojects {
|
|||
group = "com.wbrawner"
|
||||
version = "0.0.1-SNAPSHOT"
|
||||
tasks.withType<KotlinCompile> {
|
||||
kotlinOptions.jvmTarget = "14"
|
||||
kotlinOptions.jvmTarget = "16"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,8 @@ val ktorVersion: String by rootProject.extra
|
|||
|
||||
dependencies {
|
||||
implementation(kotlin("stdlib"))
|
||||
api("io.ktor:ktor-auth:$ktorVersion")
|
||||
api("at.favre.lib:bcrypt:0.9.0")
|
||||
testImplementation("org.junit.jupiter:junit-jupiter-api:5.6.0")
|
||||
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
|
||||
}
|
||||
|
|
5
core/src/main/kotlin/com/wbrawner/twigs/Identifiable.kt
Normal file
5
core/src/main/kotlin/com/wbrawner/twigs/Identifiable.kt
Normal file
|
@ -0,0 +1,5 @@
|
|||
package com.wbrawner.twigs
|
||||
|
||||
interface Identifiable {
|
||||
val id: String
|
||||
}
|
5
core/src/main/kotlin/com/wbrawner/twigs/Logger.kt
Normal file
5
core/src/main/kotlin/com/wbrawner/twigs/Logger.kt
Normal file
|
@ -0,0 +1,5 @@
|
|||
package com.wbrawner.twigs
|
||||
|
||||
interface Logger {
|
||||
fun log(message: String)
|
||||
}
|
|
@ -1,5 +1,7 @@
|
|||
package com.wbrawner.twigs
|
||||
|
||||
import at.favre.lib.crypto.bcrypt.BCrypt
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
private val CALENDAR_FIELDS = intArrayOf(
|
||||
|
@ -10,26 +12,26 @@ private val CALENDAR_FIELDS = intArrayOf(
|
|||
Calendar.DATE
|
||||
)
|
||||
|
||||
val firstOfMonth: Date
|
||||
val firstOfMonth: Instant
|
||||
get() = GregorianCalendar(TimeZone.getTimeZone("UTC")).run {
|
||||
for (calField in CALENDAR_FIELDS) {
|
||||
set(calField, getActualMinimum(calField))
|
||||
}
|
||||
time
|
||||
toInstant()
|
||||
}
|
||||
|
||||
val endOfMonth: Date
|
||||
val endOfMonth: Instant
|
||||
get() = GregorianCalendar(TimeZone.getTimeZone("UTC")).run {
|
||||
for (calField in CALENDAR_FIELDS) {
|
||||
set(calField, getActualMaximum(calField))
|
||||
}
|
||||
time
|
||||
toInstant()
|
||||
}
|
||||
|
||||
val twoWeeksFromNow: Date
|
||||
val twoWeeksFromNow: Instant
|
||||
get() = GregorianCalendar(TimeZone.getTimeZone("UTC")).run {
|
||||
add(Calendar.DATE, 14)
|
||||
time
|
||||
toInstant()
|
||||
}
|
||||
|
||||
private const val CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
@ -42,5 +44,5 @@ fun randomString(length: Int = 32): String {
|
|||
return id.toString()
|
||||
}
|
||||
|
||||
// TODO: Use bcrypt to hash strings
|
||||
fun String.hash(): String = this
|
||||
lateinit var salt: String
|
||||
fun String.hash(): String = String(BCrypt.withDefaults().hash(10, salt.toByteArray(), this.toByteArray()))
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
package com.wbrawner.twigs.model
|
||||
|
||||
import com.wbrawner.twigs.Identifiable
|
||||
import com.wbrawner.twigs.randomString
|
||||
|
||||
data class Budget(
|
||||
var id: String = randomString(),
|
||||
override val id: String = randomString(),
|
||||
var name: String? = null,
|
||||
var description: String? = null,
|
||||
var currencyCode: String? = "USD",
|
||||
)
|
||||
) : Identifiable
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
package com.wbrawner.twigs.model
|
||||
|
||||
import com.wbrawner.twigs.Identifiable
|
||||
import com.wbrawner.twigs.randomString
|
||||
|
||||
data class Category(
|
||||
val id: String = randomString(),
|
||||
var title: String = "",
|
||||
var description: String? = null,
|
||||
var amount: Long = 0L,
|
||||
var budgetId: String? = null,
|
||||
var expense: Boolean = true,
|
||||
var archived: Boolean = false
|
||||
)
|
||||
override val id: String = randomString(),
|
||||
val title: String,
|
||||
val amount: Long,
|
||||
val budgetId: String,
|
||||
val description: String? = null,
|
||||
val expense: Boolean = true,
|
||||
val archived: Boolean = false
|
||||
) : Identifiable
|
||||
|
|
|
@ -1,16 +1,17 @@
|
|||
package com.wbrawner.twigs.model
|
||||
|
||||
import com.wbrawner.twigs.Identifiable
|
||||
import com.wbrawner.twigs.randomString
|
||||
import java.time.Instant
|
||||
|
||||
data class Transaction(
|
||||
val id: String = randomString(),
|
||||
val title: String? = null,
|
||||
override val id: String = randomString(),
|
||||
val title: String,
|
||||
val description: String? = null,
|
||||
val date: Instant? = null,
|
||||
val amount: Long? = null,
|
||||
val categoryId: String? = null,
|
||||
val expense: Boolean? = null,
|
||||
val date: Instant,
|
||||
val amount: Long,
|
||||
val expense: Boolean,
|
||||
val createdBy: String,
|
||||
val categoryId: String? = null,
|
||||
val budgetId: String
|
||||
)
|
||||
) : Identifiable
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
package com.wbrawner.twigs.model
|
||||
|
||||
import com.wbrawner.twigs.Identifiable
|
||||
import com.wbrawner.twigs.randomString
|
||||
import io.ktor.auth.*
|
||||
|
||||
data class User(
|
||||
val id: String = randomString(),
|
||||
override val id: String = randomString(),
|
||||
val name: String = "",
|
||||
val password: String = "",
|
||||
val email: String? = null
|
||||
)
|
||||
) : Principal, Identifiable
|
||||
|
||||
enum class Permission {
|
||||
/**
|
||||
|
@ -33,10 +35,6 @@ enum class Permission {
|
|||
fun isAtLeast(wanted: Permission): Boolean {
|
||||
return ordinal >= wanted.ordinal
|
||||
}
|
||||
|
||||
fun isNotAtLeast(wanted: Permission): Boolean {
|
||||
return ordinal < wanted.ordinal
|
||||
}
|
||||
}
|
||||
|
||||
data class UserPermission(
|
||||
|
|
1
db/.gitignore
vendored
Normal file
1
db/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
build/
|
20
db/build.gradle.kts
Normal file
20
db/build.gradle.kts
Normal file
|
@ -0,0 +1,20 @@
|
|||
plugins {
|
||||
kotlin("jvm")
|
||||
`java-library`
|
||||
}
|
||||
|
||||
val ktorVersion: String by rootProject.extra
|
||||
|
||||
dependencies {
|
||||
implementation(kotlin("stdlib"))
|
||||
api(project(":storage"))
|
||||
implementation("org.postgresql:postgresql:42.2.23")
|
||||
api("com.zaxxer:HikariCP:5.0.0")
|
||||
implementation("ch.qos.logback:logback-classic:+")
|
||||
testImplementation("org.junit.jupiter:junit-jupiter-api:5.6.0")
|
||||
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
|
||||
}
|
||||
|
||||
tasks.getByName<Test>("test") {
|
||||
useJUnitPlatform()
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package com.wbrawner.twigs.db
|
||||
|
||||
import com.wbrawner.twigs.randomString
|
||||
|
||||
data class DatabaseMetadata(
|
||||
val version: Int = 0,
|
||||
val salt: String = randomString(16)
|
||||
)
|
|
@ -0,0 +1,31 @@
|
|||
package com.wbrawner.twigs.db
|
||||
|
||||
import com.wbrawner.twigs.model.Budget
|
||||
import com.wbrawner.twigs.storage.BudgetRepository
|
||||
import java.sql.ResultSet
|
||||
import javax.sql.DataSource
|
||||
|
||||
class JdbcBudgetRepository(dataSource: DataSource) : JdbcRepository<Budget, JdbcBudgetRepository.Fields>(dataSource),
|
||||
BudgetRepository {
|
||||
override val tableName: String = TABLE_BUDGET
|
||||
override val fields: Map<Fields, (Budget) -> Any?> = Fields.values().associateWith { it.entityField }
|
||||
override val conflictFields: Collection<String> = listOf(ID)
|
||||
|
||||
override fun ResultSet.toEntity(): Budget = Budget(
|
||||
id = getString(ID),
|
||||
name = getString(Fields.NAME.name.lowercase()),
|
||||
description = getString(Fields.DESCRIPTION.name.lowercase()),
|
||||
currencyCode = getString(Fields.CURRENCY_CODE.name.lowercase())
|
||||
)
|
||||
|
||||
enum class Fields(val entityField: (Budget) -> Any?) {
|
||||
NAME({ it.name }),
|
||||
DESCRIPTION({ it.description }),
|
||||
CURRENCY_CODE({ it.currencyCode })
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TABLE_BUDGET = "budgets"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package com.wbrawner.twigs.db
|
||||
|
||||
import com.wbrawner.twigs.model.Category
|
||||
import com.wbrawner.twigs.storage.CategoryRepository
|
||||
import java.sql.ResultSet
|
||||
import javax.sql.DataSource
|
||||
|
||||
class JdbcCategoryRepository(dataSource: DataSource) :
|
||||
JdbcRepository<Category, JdbcCategoryRepository.Fields>(dataSource), CategoryRepository {
|
||||
override val tableName: String = TABLE_CATEGORY
|
||||
override val fields: Map<Fields, (Category) -> Any?> = Fields.values().associateWith { it.entityField }
|
||||
override val conflictFields: Collection<String> = listOf(ID)
|
||||
|
||||
override fun findAll(
|
||||
budgetIds: List<String>,
|
||||
ids: List<String>?,
|
||||
expense: Boolean?,
|
||||
archived: Boolean?
|
||||
): List<Category> = dataSource.connection.use { conn ->
|
||||
if (budgetIds.isEmpty()) {
|
||||
throw Error("budgetIds cannot be empty")
|
||||
}
|
||||
val sql =
|
||||
StringBuilder("SELECT * FROM $tableName WHERE ${Fields.BUDGET_ID.name.lowercase()} in (${budgetIds.questionMarks()})")
|
||||
val params = mutableListOf<Any?>()
|
||||
params.addAll(budgetIds)
|
||||
ids?.let {
|
||||
sql.append(" AND $ID IN (${it.questionMarks()})")
|
||||
params.addAll(it)
|
||||
}
|
||||
expense?.let {
|
||||
sql.append(" AND ${Fields.EXPENSE.name.lowercase()} = ?")
|
||||
params.add(it)
|
||||
}
|
||||
archived?.let {
|
||||
sql.append(" AND ${Fields.ARCHIVED.name.lowercase()} = ?")
|
||||
params.add(it)
|
||||
}
|
||||
conn.executeQuery(sql.toString(), params)
|
||||
}
|
||||
|
||||
override fun ResultSet.toEntity(): Category = Category(
|
||||
id = getString(ID),
|
||||
title = getString(Fields.TITLE.name.lowercase()),
|
||||
description = getString(Fields.DESCRIPTION.name.lowercase()),
|
||||
amount = getLong(Fields.AMOUNT.name.lowercase()),
|
||||
expense = getBoolean(Fields.EXPENSE.name.lowercase()),
|
||||
archived = getBoolean(Fields.ARCHIVED.name.lowercase()),
|
||||
budgetId = getString(Fields.BUDGET_ID.name.lowercase()),
|
||||
)
|
||||
|
||||
enum class Fields(val entityField: (Category) -> Any?) {
|
||||
TITLE({ it.title }),
|
||||
DESCRIPTION({ it.description }),
|
||||
AMOUNT({ it.amount }),
|
||||
EXPENSE({ it.expense }),
|
||||
ARCHIVED({ it.archived }),
|
||||
BUDGET_ID({ it.budgetId }),
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TABLE_CATEGORY = "categories"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package com.wbrawner.twigs.db
|
||||
|
||||
import com.wbrawner.twigs.model.Permission
|
||||
import com.wbrawner.twigs.model.UserPermission
|
||||
import com.wbrawner.twigs.storage.PermissionRepository
|
||||
import java.sql.ResultSet
|
||||
import javax.sql.DataSource
|
||||
|
||||
class JdbcPermissionRepository(dataSource: DataSource) :
|
||||
JdbcRepository<UserPermission, JdbcPermissionRepository.Fields>(dataSource), PermissionRepository {
|
||||
override val tableName: String = TABLE_PERMISSIONS
|
||||
override val fields: Map<Fields, (UserPermission) -> Any?> = Fields.values().associateWith { it.entityField }
|
||||
override val conflictFields: Collection<String> =
|
||||
listOf(Fields.USER_ID.name.lowercase(), Fields.BUDGET_ID.name.lowercase())
|
||||
|
||||
override fun findAll(budgetIds: List<String>?, userId: String?): List<UserPermission> =
|
||||
dataSource.connection.use { conn ->
|
||||
if (budgetIds.isNullOrEmpty() && userId.isNullOrBlank()) {
|
||||
throw Error("budgetIds or userId must be provided")
|
||||
}
|
||||
val sql = StringBuilder("SELECT * FROM $tableName")
|
||||
val params = mutableListOf<String>()
|
||||
budgetIds?.let {
|
||||
sql.append(" WHERE ${Fields.BUDGET_ID.name.lowercase()} IN (${it.questionMarks()})")
|
||||
params.addAll(it)
|
||||
}
|
||||
userId?.let {
|
||||
sql.append(if (params.isEmpty()) " WHERE " else " AND ")
|
||||
sql.append("${Fields.USER_ID.name.lowercase()} = ?")
|
||||
params.add(it)
|
||||
}
|
||||
conn.executeQuery(sql.toString(), params)
|
||||
}
|
||||
|
||||
override fun ResultSet.toEntity(): UserPermission = UserPermission(
|
||||
budgetId = getString(Fields.BUDGET_ID.name.lowercase()),
|
||||
userId = getString(Fields.USER_ID.name.lowercase()),
|
||||
permission = Permission.valueOf(getString(Fields.PERMISSION.name.lowercase()))
|
||||
)
|
||||
|
||||
enum class Fields(val entityField: (UserPermission) -> Any?) {
|
||||
BUDGET_ID(
|
||||
{ it.budgetId }),
|
||||
USER_ID(
|
||||
{ it.userId }),
|
||||
PERMISSION(
|
||||
{ it.permission })
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TABLE_PERMISSIONS = "user_permissions"
|
||||
}
|
||||
}
|
||||
|
120
db/src/main/kotlin/com/wbrawner/twigs/db/JdbcRepository.kt
Normal file
120
db/src/main/kotlin/com/wbrawner/twigs/db/JdbcRepository.kt
Normal file
|
@ -0,0 +1,120 @@
|
|||
package com.wbrawner.twigs.db
|
||||
|
||||
import com.wbrawner.twigs.Identifiable
|
||||
import com.wbrawner.twigs.storage.Repository
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.sql.Connection
|
||||
import java.sql.PreparedStatement
|
||||
import java.sql.ResultSet
|
||||
import java.sql.Types.NULL
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatterBuilder
|
||||
import java.time.temporal.ChronoField
|
||||
import javax.sql.DataSource
|
||||
|
||||
const val ID = "id"
|
||||
|
||||
abstract class JdbcRepository<Entity, Fields : Enum<Fields>>(protected val dataSource: DataSource) :
|
||||
Repository<Entity> {
|
||||
abstract val tableName: String
|
||||
abstract val fields: Map<Fields, (Entity) -> Any?>
|
||||
abstract val conflictFields: Collection<String>
|
||||
val logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
override suspend fun findAll(ids: List<String>?): List<Entity> = dataSource.connection.use { conn ->
|
||||
val sql = if (!ids.isNullOrEmpty()) {
|
||||
"SELECT * FROM $tableName WHERE $ID in (${ids.questionMarks()})"
|
||||
} else {
|
||||
"SELECT * FROM $tableName"
|
||||
}
|
||||
conn.executeQuery(sql, ids ?: emptyList())
|
||||
}
|
||||
|
||||
override suspend fun save(item: Entity): Entity = dataSource.connection.use { conn ->
|
||||
val sql = StringBuilder("INSERT INTO $tableName (")
|
||||
val params = mutableListOf<Any?>()
|
||||
if (item is Identifiable) {
|
||||
sql.append("$ID, ")
|
||||
params.add(item.id)
|
||||
}
|
||||
params.addAll(fields.values.map { it(item) })
|
||||
sql.append(fields.keys.joinToString(", ") { it.name.lowercase() })
|
||||
sql.append(") VALUES (")
|
||||
sql.append(params.questionMarks())
|
||||
sql.append(")")
|
||||
if (conflictFields.isNotEmpty()) {
|
||||
sql.append(" ON CONFLICT (")
|
||||
sql.append(conflictFields.joinToString(","))
|
||||
sql.append(") DO UPDATE SET ")
|
||||
sql.append(fields.keys.joinToString(", ") {
|
||||
"${it.name.lowercase()} = EXCLUDED.${it.name.lowercase()}"
|
||||
})
|
||||
}
|
||||
return if (conn.executeUpdate(sql.toString(), params) == 1) {
|
||||
item
|
||||
} else {
|
||||
throw Error("Failed to save entity $item")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun delete(item: Entity): Boolean = dataSource.connection.use { conn ->
|
||||
if (item !is Identifiable) {
|
||||
throw Error("No suitable delete operation implemented for ${item!!::class.simpleName}")
|
||||
}
|
||||
val statement = conn.prepareStatement("DELETE FROM $tableName WHERE $ID=?")
|
||||
statement.setString(1, item.id)
|
||||
statement.executeUpdate() == 1
|
||||
}
|
||||
|
||||
private fun ResultSet.toEntityList(): List<Entity> {
|
||||
val entities = mutableListOf<Entity>()
|
||||
while (next()) {
|
||||
entities.add(toEntity())
|
||||
}
|
||||
return entities
|
||||
}
|
||||
|
||||
abstract fun ResultSet.toEntity(): Entity
|
||||
|
||||
protected fun Connection.executeQuery(sql: String, params: List<Any?>) = prepareStatement(sql)
|
||||
.apply {
|
||||
logger.debug("QUERY: $sql\nPARAMS: ${params.joinToString(", ")}")
|
||||
}
|
||||
.setParameters(params)
|
||||
.executeQuery()
|
||||
.toEntityList()
|
||||
|
||||
protected fun Connection.executeUpdate(sql: String, params: List<Any?> = emptyList()) = prepareStatement(sql)
|
||||
.apply {
|
||||
logger.debug("QUERY: $sql\nPARAMS: ${params.joinToString(", ")}")
|
||||
}
|
||||
.setParameters(params)
|
||||
.executeUpdate()
|
||||
|
||||
fun PreparedStatement.setParameters(params: Iterable<Any?>): PreparedStatement = apply {
|
||||
params.forEachIndexed { index, param ->
|
||||
when (param) {
|
||||
is Boolean -> setBoolean(index + 1, param)
|
||||
is Instant -> setString(index + 1, dateFormatter.format(param))
|
||||
is Int -> setInt(index + 1, param)
|
||||
is Long -> setLong(index + 1, param)
|
||||
is String -> setString(index + 1, param)
|
||||
is Enum<*> -> setString(index + 1, param.name)
|
||||
null -> setNull(index + 1, NULL)
|
||||
else -> throw Error("Unhandled parameter type: ${param?.javaClass?.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> Collection<T>.questionMarks(): String = List(this.size) { "?" }.joinToString(", ")
|
||||
|
||||
|
||||
private val dateFormatter = DateTimeFormatterBuilder()
|
||||
.appendPattern("yyyy-MM-dd HH:mm:ss")
|
||||
.appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true)
|
||||
.toFormatter()
|
||||
.withZone(ZoneId.of("UTC"))
|
||||
|
||||
fun ResultSet.getInstant(column: String): Instant = dateFormatter.parse(getString(column), Instant::from)
|
|
@ -0,0 +1,47 @@
|
|||
package com.wbrawner.twigs.db
|
||||
|
||||
import com.wbrawner.twigs.storage.Session
|
||||
import com.wbrawner.twigs.storage.SessionRepository
|
||||
import java.sql.ResultSet
|
||||
import java.time.Instant
|
||||
import javax.sql.DataSource
|
||||
|
||||
class JdbcSessionRepository(dataSource: DataSource) : JdbcRepository<Session, JdbcSessionRepository.Fields>(dataSource),
|
||||
SessionRepository {
|
||||
override val tableName: String = TABLE_SESSION
|
||||
override val fields: Map<Fields, (Session) -> Any?> = Fields.values().associateWith { it.entityField }
|
||||
override val conflictFields: Collection<String> = listOf(ID)
|
||||
|
||||
override fun findAll(token: String): List<Session> = dataSource.connection.use { conn ->
|
||||
val sql = "SELECT * FROM $tableName WHERE ${Fields.TOKEN.name.lowercase()} = ?"
|
||||
val params = mutableListOf(token)
|
||||
conn.executeQuery(sql, params)
|
||||
}
|
||||
|
||||
override fun deleteExpired() {
|
||||
dataSource.connection.use { conn ->
|
||||
val sql = "DELETE FROM $tableName WHERE ${Fields.TOKEN.name.lowercase()} < ?"
|
||||
val params = mutableListOf(Instant.now())
|
||||
conn.executeUpdate(sql, params)
|
||||
}
|
||||
}
|
||||
|
||||
override fun ResultSet.toEntity(): Session = Session(
|
||||
id = getString(ID),
|
||||
userId = getString(Fields.USER_ID.name.lowercase()),
|
||||
token = getString(Fields.TOKEN.name.lowercase()),
|
||||
expiration = getInstant(Fields.EXPIRATION.name.lowercase()),
|
||||
)
|
||||
|
||||
enum class Fields(val entityField: (Session) -> Any?) {
|
||||
USER_ID({ it.userId }),
|
||||
TOKEN({ it.token }),
|
||||
EXPIRATION({ it.expiration }),
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TABLE_SESSION = "sessions"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
package com.wbrawner.twigs.db
|
||||
|
||||
import com.wbrawner.twigs.model.Transaction
|
||||
import com.wbrawner.twigs.storage.TransactionRepository
|
||||
import java.sql.ResultSet
|
||||
import java.time.Instant
|
||||
import javax.sql.DataSource
|
||||
|
||||
class JdbcTransactionRepository(dataSource: DataSource) :
|
||||
JdbcRepository<Transaction, JdbcTransactionRepository.Fields>(dataSource), TransactionRepository {
|
||||
override val tableName: String = TABLE_TRANSACTION
|
||||
override val fields: Map<Fields, (Transaction) -> Any?> = Fields.values().associateWith { it.entityField }
|
||||
override val conflictFields: Collection<String> = listOf(ID)
|
||||
|
||||
override fun findAll(
|
||||
ids: List<String>?,
|
||||
budgetIds: List<String>?,
|
||||
categoryIds: List<String>?,
|
||||
expense: Boolean?,
|
||||
from: Instant?,
|
||||
to: Instant?
|
||||
): List<Transaction> = dataSource.connection.use { conn ->
|
||||
val sql = StringBuilder("SELECT * FROM $tableName")
|
||||
val params = mutableListOf<Any?>(budgetIds)
|
||||
|
||||
fun queryWord(): String = if (params.isEmpty()) " WHERE" else " AND"
|
||||
|
||||
ids?.let {
|
||||
sql.append("${queryWord()} $ID IN (${it.questionMarks()})")
|
||||
params.addAll(it)
|
||||
}
|
||||
budgetIds?.let {
|
||||
sql.append("${queryWord()} ${Fields.BUDGET_ID.name.lowercase()} IN (${it.questionMarks()})")
|
||||
params.addAll(it)
|
||||
}
|
||||
categoryIds?.let {
|
||||
sql.append("${queryWord()} ${Fields.CATEGORY_ID.name.lowercase()} IN (${it.questionMarks()})")
|
||||
params.addAll(it)
|
||||
}
|
||||
expense?.let {
|
||||
sql.append("${queryWord()} ${Fields.EXPENSE.name.lowercase()} = ?")
|
||||
params.add(it)
|
||||
}
|
||||
from?.let {
|
||||
sql.append("${queryWord()} ${Fields.DATE.name.lowercase()} >= ?")
|
||||
params.add(it)
|
||||
}
|
||||
to?.let {
|
||||
sql.append("${queryWord()} ${Fields.DATE.name.lowercase()} <= ?")
|
||||
params.add(it)
|
||||
}
|
||||
conn.executeQuery(sql.toString(), params)
|
||||
}
|
||||
|
||||
override fun sumByBudget(budgetId: String, from: Instant, to: Instant): Long =
|
||||
querySum(Fields.BUDGET_ID, budgetId, from, to)
|
||||
|
||||
override fun sumByCategory(categoryId: String, from: Instant, to: Instant): Long =
|
||||
querySum(Fields.CATEGORY_ID, categoryId, from, to)
|
||||
|
||||
private fun querySum(field: Fields, id: String, from: Instant?, to: Instant?): Long =
|
||||
dataSource.connection.use { conn ->
|
||||
val sql =
|
||||
StringBuilder("SELECT SUM(${Fields.AMOUNT.name.lowercase()}) FROM $tableName WHERE ${field.name.lowercase()} = ?")
|
||||
val params = mutableListOf<Any?>(id)
|
||||
from?.let {
|
||||
sql.append(" AND ${Fields.DATE.name.lowercase()} >= ?")
|
||||
params.add(it)
|
||||
}
|
||||
to?.let {
|
||||
sql.append(" AND ${Fields.DATE.name.lowercase()} <= ?")
|
||||
params.add(it)
|
||||
}
|
||||
sql.append(" AND ${Fields.EXPENSE.name.lowercase()} = ?")
|
||||
conn.prepareStatement("SELECT (${sql.toString().coalesce()}) - (${sql.toString().coalesce()})")
|
||||
.setParameters(params + false + params + true)
|
||||
.executeQuery()
|
||||
.getLong(1)
|
||||
}
|
||||
|
||||
private fun String.coalesce(): String = "COALESCE(($this), 0)"
|
||||
|
||||
override fun ResultSet.toEntity(): Transaction = Transaction(
|
||||
id = getString(ID),
|
||||
title = getString(Fields.TITLE.name.lowercase()),
|
||||
description = getString(Fields.DESCRIPTION.name.lowercase()),
|
||||
date = Instant.parse(getString(Fields.DATE.name.lowercase())),
|
||||
amount = getLong(Fields.AMOUNT.name.lowercase()),
|
||||
expense = getBoolean(Fields.EXPENSE.name.lowercase()),
|
||||
createdBy = getString(Fields.CREATED_BY.name.lowercase()),
|
||||
categoryId = getString(Fields.CATEGORY_ID.name.lowercase()),
|
||||
budgetId = getString(Fields.BUDGET_ID.name.lowercase()),
|
||||
)
|
||||
|
||||
enum class Fields(val entityField: (Transaction) -> Any?) {
|
||||
TITLE({ it.title }),
|
||||
DESCRIPTION({ it.description }),
|
||||
DATE({ it.date }),
|
||||
AMOUNT({ it.amount }),
|
||||
EXPENSE({ it.expense }),
|
||||
CREATED_BY({ it.createdBy }),
|
||||
CATEGORY_ID({ it.categoryId }),
|
||||
BUDGET_ID({ it.budgetId }),
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TABLE_TRANSACTION = "transactions"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package com.wbrawner.twigs.db
|
||||
|
||||
import com.wbrawner.twigs.model.User
|
||||
import com.wbrawner.twigs.storage.UserRepository
|
||||
import java.sql.ResultSet
|
||||
import javax.sql.DataSource
|
||||
|
||||
class JdbcUserRepository(dataSource: DataSource) : JdbcRepository<User, JdbcUserRepository.Fields>(dataSource),
|
||||
UserRepository {
|
||||
override val tableName: String = TABLE_USER
|
||||
override val fields: Map<Fields, (User) -> Any?> = Fields.values().associateWith { it.entityField }
|
||||
override val conflictFields: Collection<String> = listOf(ID)
|
||||
|
||||
override fun ResultSet.toEntity(): User = User(
|
||||
id = getString(ID),
|
||||
name = getString(Fields.USERNAME.name.lowercase()),
|
||||
password = getString(Fields.PASSWORD.name.lowercase()),
|
||||
email = getString(Fields.EMAIL.name.lowercase())
|
||||
)
|
||||
|
||||
override fun findAll(nameLike: String): List<User> = dataSource.connection.use { conn ->
|
||||
conn.executeQuery(
|
||||
"SELECT * FROM $tableName WHERE ${Fields.USERNAME.name.lowercase()} LIKE ? || '%'",
|
||||
listOf(nameLike)
|
||||
)
|
||||
}
|
||||
|
||||
override fun findAll(nameOrEmail: String, password: String): List<User> = dataSource.connection.use { conn ->
|
||||
conn.executeQuery(
|
||||
"SELECT * FROM $tableName WHERE (${Fields.USERNAME.name.lowercase()} = ? OR ${Fields.EMAIL.name.lowercase()} = ?) AND ${Fields.PASSWORD.name.lowercase()} = ?",
|
||||
listOf(nameOrEmail, nameOrEmail, password)
|
||||
)
|
||||
}
|
||||
|
||||
enum class Fields(val entityField: (User) -> Any?) {
|
||||
USERNAME({ it.name }),
|
||||
PASSWORD({ it.password }),
|
||||
EMAIL({ it.email })
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TABLE_USER = "users"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package com.wbrawner.twigs.db
|
||||
|
||||
import java.sql.ResultSet
|
||||
import java.sql.SQLException
|
||||
import javax.sql.DataSource
|
||||
|
||||
class MetadataRepository(dataSource: DataSource) :
|
||||
JdbcRepository<DatabaseMetadata, MetadataRepository.Fields>(dataSource) {
|
||||
override val tableName: String = TABLE_METADATA
|
||||
override val fields: Map<Fields, (DatabaseMetadata) -> Any?> = Fields.values().associateWith { it.entityField }
|
||||
override val conflictFields: Collection<String> = listOf()
|
||||
|
||||
suspend fun runMigration(toVersion: Int) {
|
||||
val queries = MetadataRepository::class.java
|
||||
.getResource("/sql/$toVersion.sql")
|
||||
?.readText()
|
||||
?.split(";")
|
||||
?: throw Error("No migration found for version $toVersion")
|
||||
dataSource.connection.use { conn ->
|
||||
queries.forEach { query ->
|
||||
conn.executeUpdate(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun delete(item: DatabaseMetadata): Boolean = throw Error("DatabaseMetadata cannot be deleted")
|
||||
|
||||
override suspend fun findAll(ids: List<String>?): List<DatabaseMetadata> = try {
|
||||
super.findAll(null)
|
||||
} catch (e: SQLException) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
override suspend fun save(item: DatabaseMetadata): DatabaseMetadata = dataSource.connection.use { conn ->
|
||||
conn.executeUpdate("DELETE FROM $tableName", emptyList())
|
||||
super.save(item)
|
||||
}
|
||||
|
||||
override fun ResultSet.toEntity(): DatabaseMetadata = DatabaseMetadata(
|
||||
version = getInt(Fields.VERSION.name.lowercase()),
|
||||
salt = getString(Fields.SALT.name.lowercase())
|
||||
)
|
||||
|
||||
enum class Fields(val entityField: (DatabaseMetadata) -> Any?) {
|
||||
VERSION({ it.version }),
|
||||
SALT({ it.salt }),
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TABLE_METADATA = "twigs_metadata"
|
||||
}
|
||||
}
|
|
@ -8,23 +8,18 @@ services:
|
|||
depends_on:
|
||||
- db
|
||||
environment:
|
||||
SPRING_DATASOURCE_URL: "jdbc:mysql://db:3306/budget?useSSL=false"
|
||||
SPRING_JPA_HIBERNATE_DDL-AUTO: update
|
||||
SERVER_TOMCAT_MAX-THREADS: 5
|
||||
TWIGS_CORS_DOMAINS: "http://localhost:4200"
|
||||
TWIGS_DB_HOST: db
|
||||
networks:
|
||||
- twigs
|
||||
command: sh -c "sleep 5 && /opt/java/openjdk/bin/java $JVM_ARGS -jar /twigs-api.jar"
|
||||
|
||||
db:
|
||||
image: mysql:5.7
|
||||
image: postgres:13
|
||||
ports:
|
||||
- "3306:3306"
|
||||
- "5432:5432"
|
||||
environment:
|
||||
MYSQL_RANDOM_ROOT_PASSWORD: "yes"
|
||||
MYSQL_DATABASE: budget
|
||||
MYSQL_USER: budget
|
||||
MYSQL_PASSWORD: budget
|
||||
POSTGRES_DB: twigs
|
||||
POSTGRES_USER: twigs
|
||||
POSTGRES_PASSWORD: twigs
|
||||
networks:
|
||||
- twigs
|
||||
hostname: db
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
rootProject.name = "twigs"
|
||||
include("core", "api", "app")
|
||||
include("storage")
|
||||
include("db")
|
||||
|
|
|
@ -7,10 +7,8 @@ val ktorVersion: String by rootProject.extra
|
|||
|
||||
dependencies {
|
||||
implementation(kotlin("stdlib"))
|
||||
implementation(project(":core"))
|
||||
api("io.ktor:ktor-auth:$ktorVersion")
|
||||
api(project(":core"))
|
||||
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0")
|
||||
implementation("org.postgresql:postgresql:42.2.23")
|
||||
testImplementation("org.junit.jupiter:junit-jupiter-api:5.6.0")
|
||||
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
|
||||
}
|
||||
|
|
|
@ -4,8 +4,8 @@ import com.wbrawner.twigs.model.Category
|
|||
|
||||
interface CategoryRepository : Repository<Category> {
|
||||
fun findAll(
|
||||
budgetIds: List<String>,
|
||||
ids: List<String>? = null,
|
||||
budgetIds: List<String>? = null,
|
||||
expense: Boolean? = null,
|
||||
archived: Boolean? = null
|
||||
): List<Category>
|
||||
|
|
|
@ -6,8 +6,7 @@ package com.wbrawner.twigs.storage
|
|||
* @param T The type of the object supported by this repository
|
||||
*/
|
||||
interface Repository<T> {
|
||||
suspend fun findAll(): List<T>
|
||||
suspend fun findAllByIds(id: List<String>): List<T>
|
||||
suspend fun findAll(ids: List<String>? = null): List<T>
|
||||
suspend fun save(item: T): T
|
||||
suspend fun delete(item: T): Boolean
|
||||
}
|
|
@ -1,13 +1,14 @@
|
|||
package com.wbrawner.twigs.storage
|
||||
|
||||
import com.wbrawner.twigs.Identifiable
|
||||
import com.wbrawner.twigs.randomString
|
||||
import com.wbrawner.twigs.twoWeeksFromNow
|
||||
import io.ktor.auth.*
|
||||
import java.util.*
|
||||
import java.time.Instant
|
||||
|
||||
data class Session(
|
||||
override val id: String = randomString(),
|
||||
val userId: String = "",
|
||||
val id: String = randomString(),
|
||||
val token: String = randomString(255),
|
||||
var expiration: Date = twoWeeksFromNow
|
||||
) : Principal
|
||||
var expiration: Instant = twoWeeksFromNow
|
||||
) : Principal, Identifiable
|
||||
|
|
|
@ -4,16 +4,9 @@ import com.wbrawner.twigs.model.User
|
|||
|
||||
interface UserRepository : Repository<User> {
|
||||
fun findAll(
|
||||
ids: List<String>? = null,
|
||||
): List<User>
|
||||
|
||||
fun find(
|
||||
name: String? = null,
|
||||
email: String? = null,
|
||||
password: String? = null
|
||||
nameOrEmail: String,
|
||||
password: String
|
||||
): List<User>
|
||||
|
||||
fun findAll(nameLike: String): List<User>
|
||||
|
||||
fun deleteById(id: String): Boolean
|
||||
}
|
Loading…
Reference in a new issue