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
|
FROM openjdk:14-jdk as builder
|
||||||
MAINTAINER William Brawner <me@wbrawner.com>
|
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
|
FROM adoptopenjdk:openj9
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
COPY --from=builder /home/gradle/src/api/build/libs/api-0.0.1-SNAPSHOT.jar twigs-api.jar
|
COPY app/build/libs/twigs.jar twigs.jar
|
||||||
CMD /opt/java/openjdk/bin/java $JVM_ARGS -jar /twigs-api.jar
|
CMD /opt/java/openjdk/bin/java $JVM_ARGS -jar /twigs.jar
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
plugins {
|
plugins {
|
||||||
kotlin("jvm")
|
kotlin("jvm")
|
||||||
|
kotlin("plugin.serialization") version "1.5.20"
|
||||||
`java-library`
|
`java-library`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -10,6 +11,7 @@ dependencies {
|
||||||
api(project(":core"))
|
api(project(":core"))
|
||||||
implementation(project(":storage"))
|
implementation(project(":storage"))
|
||||||
api("io.ktor:ktor-server-core:$ktorVersion")
|
api("io.ktor:ktor-server-core:$ktorVersion")
|
||||||
|
api("io.ktor:ktor-serialization:$ktorVersion")
|
||||||
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0")
|
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0")
|
||||||
testImplementation("org.junit.jupiter:junit-jupiter-api:5.6.0")
|
testImplementation("org.junit.jupiter:junit-jupiter-api:5.6.0")
|
||||||
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
|
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
|
||||||
|
|
|
@ -42,11 +42,11 @@ suspend fun PipelineContext<Unit, ApplicationCall>.budgetWithPermission(
|
||||||
userId = session.userId,
|
userId = session.userId,
|
||||||
budgetIds = listOf(budgetId)
|
budgetIds = listOf(budgetId)
|
||||||
).firstOrNull()
|
).firstOrNull()
|
||||||
if (userPermission?.permission?.isNotAtLeast(permission) != true) {
|
if (userPermission?.permission?.isAtLeast(permission) != true) {
|
||||||
errorResponse(HttpStatusCode.Forbidden)
|
errorResponse(HttpStatusCode.Forbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
block(budgetRepository.findAllByIds(listOf(budgetId)).first())
|
block(budgetRepository.findAll(ids = listOf(budgetId)).first())
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend inline fun PipelineContext<Unit, ApplicationCall>.errorResponse(
|
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.Budget
|
||||||
import com.wbrawner.twigs.model.UserPermission
|
import com.wbrawner.twigs.model.UserPermission
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class BudgetRequest(
|
data class BudgetRequest(
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
val users: Set<UserPermissionRequest>? = null
|
val users: Set<UserPermissionRequest>? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class BudgetResponse(
|
data class BudgetResponse(
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: 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 {
|
routing {
|
||||||
route("/api/budgets") {
|
route("/api/budgets") {
|
||||||
authenticate(optional = false) {
|
authenticate(optional = false) {
|
||||||
get("/") {
|
get {
|
||||||
val session = call.principal<Session>()!!
|
val session = call.principal<Session>()!!
|
||||||
val budgetIds = permissionRepository.findAll(userId = session.userId).map { it.budgetId }
|
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)))
|
BudgetResponse(it, permissionRepository.findAll(budgetIds = listOf(it.id)))
|
||||||
}
|
}
|
||||||
call.respond(budgets)
|
call.respond(budgets)
|
||||||
|
@ -36,7 +40,7 @@ fun Application.budgetRoutes(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
post("/") {
|
post {
|
||||||
val session = call.principal<Session>()!!
|
val session = call.principal<Session>()!!
|
||||||
val request = call.receive<BudgetRequest>()
|
val request = call.receive<BudgetRequest>()
|
||||||
if (request.name.isNullOrBlank()) {
|
if (request.name.isNullOrBlank()) {
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
package com.wbrawner.twigs
|
package com.wbrawner.twigs
|
||||||
|
|
||||||
import com.wbrawner.twigs.model.Category
|
import com.wbrawner.twigs.model.Category
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class CategoryRequest(
|
data class CategoryRequest(
|
||||||
val title: String? = null,
|
val title: String? = null,
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
|
@ -11,24 +13,23 @@ data class CategoryRequest(
|
||||||
val archived: Boolean? = null
|
val archived: Boolean? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class CategoryResponse(
|
data class CategoryResponse(
|
||||||
val id: String,
|
val id: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val description: String?,
|
val description: String?,
|
||||||
val amount: Long,
|
val amount: Long,
|
||||||
val budgetId: String,
|
val budgetId: String,
|
||||||
val isExpense: Boolean,
|
val expense: Boolean,
|
||||||
val isArchived: Boolean
|
val archived: Boolean
|
||||||
) {
|
)
|
||||||
constructor(category: Category) : this(
|
|
||||||
category.id,
|
|
||||||
category.title,
|
|
||||||
category.description,
|
|
||||||
category.amount,
|
|
||||||
category.budgetId!!,
|
|
||||||
category.expense,
|
|
||||||
category.archived
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
routing {
|
||||||
route("/api/categories") {
|
route("/api/categories") {
|
||||||
authenticate(optional = false) {
|
authenticate(optional = false) {
|
||||||
get("/") {
|
get {
|
||||||
val session = call.principal<Session>()!!
|
val session = call.principal<Session>()!!
|
||||||
|
val budgetIds = permissionRepository.findAll(
|
||||||
|
budgetIds = call.request.queryParameters.getAll("budgetIds"),
|
||||||
|
userId = session.userId
|
||||||
|
).map { it.budgetId }
|
||||||
|
if (budgetIds.isEmpty()) {
|
||||||
|
call.respond(emptyList<CategoryResponse>())
|
||||||
|
return@get
|
||||||
|
}
|
||||||
call.respond(categoryRepository.findAll(
|
call.respond(categoryRepository.findAll(
|
||||||
budgetIds = permissionRepository.findAll(
|
budgetIds = budgetIds,
|
||||||
budgetIds = call.request.queryParameters.getAll("budgetIds"),
|
|
||||||
userId = session.userId
|
|
||||||
).map { it.budgetId },
|
|
||||||
expense = call.request.queryParameters["expense"]?.toBoolean(),
|
expense = call.request.queryParameters["expense"]?.toBoolean(),
|
||||||
archived = call.request.queryParameters["archived"]?.toBoolean()
|
archived = call.request.queryParameters["archived"]?.toBoolean()
|
||||||
).map { CategoryResponse(it) })
|
).map { it.asResponse() })
|
||||||
}
|
}
|
||||||
|
|
||||||
get("/{id}") {
|
get("/{id}") {
|
||||||
val session = call.principal<Session>()!!
|
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(
|
call.respond(categoryRepository.findAll(
|
||||||
ids = call.parameters.getAll("id"),
|
ids = call.parameters.getAll("id"),
|
||||||
budgetIds = permissionRepository.findAll(
|
budgetIds = budgetIds
|
||||||
userId = session.userId
|
).map { it.asResponse() })
|
||||||
).map { it.budgetId }
|
|
||||||
).map { CategoryResponse(it) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
post("/") {
|
post {
|
||||||
val session = call.principal<Session>()!!
|
val session = call.principal<Session>()!!
|
||||||
val request = call.receive<CategoryRequest>()
|
val request = call.receive<CategoryRequest>()
|
||||||
if (request.title.isNullOrBlank()) {
|
if (request.title.isNullOrBlank()) {
|
||||||
|
@ -61,16 +69,15 @@ fun Application.categoryRoutes(
|
||||||
return@post
|
return@post
|
||||||
}
|
}
|
||||||
call.respond(
|
call.respond(
|
||||||
CategoryResponse(
|
categoryRepository.save(
|
||||||
categoryRepository.save(
|
Category(
|
||||||
Category(
|
title = request.title,
|
||||||
title = request.title,
|
description = request.description,
|
||||||
description = request.description,
|
amount = request.amount ?: 0L,
|
||||||
amount = request.amount ?: 0L,
|
expense = request.expense ?: true,
|
||||||
expense = request.expense ?: true,
|
budgetId = request.budgetId
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
).asResponse()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -86,23 +93,21 @@ fun Application.categoryRoutes(
|
||||||
requireBudgetWithPermission(
|
requireBudgetWithPermission(
|
||||||
permissionRepository,
|
permissionRepository,
|
||||||
session.userId,
|
session.userId,
|
||||||
category.budgetId!!,
|
category.budgetId,
|
||||||
Permission.WRITE
|
Permission.WRITE
|
||||||
) {
|
) {
|
||||||
return@put
|
return@put
|
||||||
}
|
}
|
||||||
call.respond(
|
call.respond(
|
||||||
CategoryResponse(
|
categoryRepository.save(
|
||||||
categoryRepository.save(
|
category.copy(
|
||||||
Category(
|
title = request.title ?: category.title,
|
||||||
title = request.title ?: category.title,
|
description = request.description ?: category.description,
|
||||||
description = request.description ?: category.description,
|
amount = request.amount ?: category.amount,
|
||||||
amount = request.amount ?: category.amount,
|
expense = request.expense ?: category.expense,
|
||||||
expense = request.expense ?: category.expense,
|
archived = request.archived ?: category.archived,
|
||||||
archived = request.archived ?: category.archived
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
).asResponse()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -117,7 +122,7 @@ fun Application.categoryRoutes(
|
||||||
requireBudgetWithPermission(
|
requireBudgetWithPermission(
|
||||||
permissionRepository,
|
permissionRepository,
|
||||||
session.userId,
|
session.userId,
|
||||||
category.budgetId!!,
|
category.budgetId,
|
||||||
Permission.WRITE
|
Permission.WRITE
|
||||||
) {
|
) {
|
||||||
return@delete
|
return@delete
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
package com.wbrawner.twigs
|
package com.wbrawner.twigs
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class ErrorResponse(val message: String)
|
data class ErrorResponse(val message: String)
|
|
@ -1,7 +1,9 @@
|
||||||
package com.wbrawner.twigs
|
package com.wbrawner.twigs
|
||||||
|
|
||||||
import com.wbrawner.twigs.model.Transaction
|
import com.wbrawner.twigs.model.Transaction
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class TransactionRequest(
|
data class TransactionRequest(
|
||||||
val title: String? = null,
|
val title: String? = null,
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
|
@ -12,6 +14,7 @@ data class TransactionRequest(
|
||||||
val budgetId: String? = null,
|
val budgetId: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class TransactionResponse(
|
data class TransactionResponse(
|
||||||
val id: String,
|
val id: String,
|
||||||
val title: String?,
|
val title: String?,
|
||||||
|
@ -24,6 +27,7 @@ data class TransactionResponse(
|
||||||
val createdBy: String
|
val createdBy: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class BalanceResponse(val balance: Long)
|
data class BalanceResponse(val balance: Long)
|
||||||
|
|
||||||
fun Transaction.asResponse(): TransactionResponse = TransactionResponse(
|
fun Transaction.asResponse(): TransactionResponse = TransactionResponse(
|
||||||
|
|
|
@ -20,17 +20,18 @@ fun Application.transactionRoutes(
|
||||||
routing {
|
routing {
|
||||||
route("/api/transactions") {
|
route("/api/transactions") {
|
||||||
authenticate(optional = false) {
|
authenticate(optional = false) {
|
||||||
get("/") {
|
get {
|
||||||
val session = call.principal<Session>()!!
|
val session = call.principal<Session>()!!
|
||||||
call.respond(transactionRepository.findAll(
|
call.respond(
|
||||||
budgetIds = permissionRepository.findAll(
|
transactionRepository.findAll(
|
||||||
budgetIds = call.request.queryParameters.getAll("budgetIds"),
|
budgetIds = permissionRepository.findAll(
|
||||||
userId = session.userId
|
budgetIds = call.request.queryParameters.getAll("budgetIds"),
|
||||||
).map { it.budgetId },
|
userId = session.userId
|
||||||
categoryIds = call.request.queryParameters.getAll("categoryIds"),
|
).map { it.budgetId },
|
||||||
from = call.request.queryParameters["from"]?.let { Instant.parse(it) },
|
categoryIds = call.request.queryParameters.getAll("categoryIds"),
|
||||||
to = call.request.queryParameters["to"]?.let { Instant.parse(it) },
|
from = call.request.queryParameters["from"]?.let { Instant.parse(it) },
|
||||||
expense = call.request.queryParameters["expense"]?.toBoolean(),
|
to = call.request.queryParameters["to"]?.let { Instant.parse(it) },
|
||||||
|
expense = call.request.queryParameters["expense"]?.toBoolean(),
|
||||||
).map { it.asResponse() })
|
).map { it.asResponse() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -53,11 +54,14 @@ fun Application.transactionRoutes(
|
||||||
get("/sum") {
|
get("/sum") {
|
||||||
val categoryId = call.request.queryParameters["categoryId"]
|
val categoryId = call.request.queryParameters["categoryId"]
|
||||||
val budgetId = call.request.queryParameters["budgetId"]
|
val budgetId = call.request.queryParameters["budgetId"]
|
||||||
val from = call.request.queryParameters["from"]?.toInstant() ?: firstOfMonth.toInstant()
|
val from = call.request.queryParameters["from"]?.toInstant() ?: firstOfMonth
|
||||||
val to = call.request.queryParameters["to"]?.toInstant() ?: endOfMonth.toInstant()
|
val to = call.request.queryParameters["to"]?.toInstant() ?: endOfMonth
|
||||||
val balance = if (!categoryId.isNullOrBlank()) {
|
val balance = if (!categoryId.isNullOrBlank()) {
|
||||||
if (!budgetId.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
|
return@get
|
||||||
}
|
}
|
||||||
transactionRepository.sumByCategory(categoryId, from, to)
|
transactionRepository.sumByCategory(categoryId, from, to)
|
||||||
|
@ -70,7 +74,7 @@ fun Application.transactionRoutes(
|
||||||
call.respond(BalanceResponse(balance))
|
call.respond(BalanceResponse(balance))
|
||||||
}
|
}
|
||||||
|
|
||||||
post("/") {
|
post {
|
||||||
val session = call.principal<Session>()!!
|
val session = call.principal<Session>()!!
|
||||||
val request = call.receive<TransactionRequest>()
|
val request = call.receive<TransactionRequest>()
|
||||||
if (request.title.isNullOrBlank()) {
|
if (request.title.isNullOrBlank()) {
|
||||||
|
|
|
@ -3,26 +3,33 @@ package com.wbrawner.twigs
|
||||||
import com.wbrawner.twigs.model.Permission
|
import com.wbrawner.twigs.model.Permission
|
||||||
import com.wbrawner.twigs.model.User
|
import com.wbrawner.twigs.model.User
|
||||||
import com.wbrawner.twigs.storage.Session
|
import com.wbrawner.twigs.storage.Session
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class UserRequest(
|
data class UserRequest(
|
||||||
val username: String? = null,
|
val username: String? = null,
|
||||||
val password: String? = null,
|
val password: String? = null,
|
||||||
val email: String? = null
|
val email: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class LoginRequest(val username: String, val password: String)
|
data class LoginRequest(val username: String, val password: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class UserResponse(val id: String, val username: String, val email: String?)
|
data class UserResponse(val id: String, val username: String, val email: String?)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class UserPermissionRequest(
|
data class UserPermissionRequest(
|
||||||
val user: String,
|
val user: String,
|
||||||
val permission: Permission = Permission.READ
|
val permission: Permission = Permission.READ
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class UserPermissionResponse(val user: String, val permission: Permission?)
|
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(
|
data class PasswordResetRequest(
|
||||||
val userId: Long,
|
val userId: Long,
|
||||||
|
@ -33,4 +40,4 @@ data class PasswordResetRequest(
|
||||||
|
|
||||||
fun User.asResponse(): UserResponse = UserResponse(id, name, email)
|
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.request.*
|
||||||
import io.ktor.response.*
|
import io.ktor.response.*
|
||||||
import io.ktor.routing.*
|
import io.ktor.routing.*
|
||||||
import io.ktor.sessions.*
|
|
||||||
|
|
||||||
fun Application.userRoutes(
|
fun Application.userRoutes(
|
||||||
permissionRepository: PermissionRepository,
|
permissionRepository: PermissionRepository,
|
||||||
|
@ -23,15 +22,15 @@ fun Application.userRoutes(
|
||||||
post("/login") {
|
post("/login") {
|
||||||
val request = call.receive<LoginRequest>()
|
val request = call.receive<LoginRequest>()
|
||||||
val user =
|
val user =
|
||||||
userRepository.find(name = request.username, password = request.password.hash()).firstOrNull()
|
userRepository.findAll(nameOrEmail = request.username, password = request.password.hash())
|
||||||
?: userRepository.find(email = request.username, password = request.password.hash())
|
.firstOrNull()
|
||||||
|
?: userRepository.findAll(nameOrEmail = request.username, password = request.password.hash())
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
?: run {
|
?: run {
|
||||||
errorResponse(HttpStatusCode.Unauthorized, "Invalid credentials")
|
errorResponse(HttpStatusCode.Unauthorized, "Invalid credentials")
|
||||||
return@post
|
return@post
|
||||||
}
|
}
|
||||||
val session = sessionRepository.save(Session(userId = user.id))
|
val session = sessionRepository.save(Session(userId = user.id))
|
||||||
call.sessions.set(session)
|
|
||||||
call.respond(session.asResponse())
|
call.respond(session.asResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,7 +48,7 @@ fun Application.userRoutes(
|
||||||
userRepository.save(
|
userRepository.save(
|
||||||
User(
|
User(
|
||||||
name = request.username,
|
name = request.username,
|
||||||
password = request.password,
|
password = request.password.hash(),
|
||||||
email = request.email
|
email = request.email
|
||||||
)
|
)
|
||||||
).asResponse()
|
).asResponse()
|
||||||
|
@ -57,10 +56,10 @@ fun Application.userRoutes(
|
||||||
}
|
}
|
||||||
|
|
||||||
authenticate(optional = false) {
|
authenticate(optional = false) {
|
||||||
get("/") {
|
get {
|
||||||
val query = call.request.queryParameters.getAll("query")
|
val query = call.request.queryParameters.getAll("query")
|
||||||
if (query?.firstOrNull()?.isNotBlank() == true) {
|
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
|
return@get
|
||||||
}
|
}
|
||||||
permissionRepository.findAll(
|
permissionRepository.findAll(
|
||||||
|
@ -76,10 +75,11 @@ fun Application.userRoutes(
|
||||||
userRepository.findAll(ids = call.parameters.getAll("id"))
|
userRepository.findAll(ids = call.parameters.getAll("id"))
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
?.asResponse()
|
?.asResponse()
|
||||||
|
?.let { call.respond(it) }
|
||||||
?: errorResponse(HttpStatusCode.NotFound)
|
?: errorResponse(HttpStatusCode.NotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
post("/") {
|
post {
|
||||||
val request = call.receive<UserRequest>()
|
val request = call.receive<UserRequest>()
|
||||||
if (request.username.isNullOrBlank()) {
|
if (request.username.isNullOrBlank()) {
|
||||||
errorResponse(HttpStatusCode.BadRequest, "Username must not be null or blank")
|
errorResponse(HttpStatusCode.BadRequest, "Username must not be null or blank")
|
||||||
|
@ -126,11 +126,16 @@ fun Application.userRoutes(
|
||||||
delete("/{id}") {
|
delete("/{id}") {
|
||||||
val session = call.principal<Session>()!!
|
val session = call.principal<Session>()!!
|
||||||
// TODO: Add some kind of admin denotation to allow admins to delete other users
|
// 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)
|
errorResponse(HttpStatusCode.Forbidden)
|
||||||
return@delete
|
return@delete
|
||||||
}
|
}
|
||||||
userRepository.deleteById(session.userId)
|
userRepository.delete(user)
|
||||||
call.respond(HttpStatusCode.NoContent)
|
call.respond(HttpStatusCode.NoContent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,12 +3,10 @@ import java.net.URI
|
||||||
plugins {
|
plugins {
|
||||||
java
|
java
|
||||||
kotlin("jvm")
|
kotlin("jvm")
|
||||||
id("org.springframework.boot")
|
|
||||||
application
|
application
|
||||||
|
id("com.github.johnrengelman.shadow") version "7.0.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
apply(plugin = "io.spring.dependency-management")
|
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenLocal()
|
mavenLocal()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
@ -23,7 +21,7 @@ val ktorVersion: String by rootProject.extra
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":api"))
|
implementation(project(":api"))
|
||||||
implementation(project(":core"))
|
implementation(project(":core"))
|
||||||
implementation(project(":storage"))
|
implementation(project(":db"))
|
||||||
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
|
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
|
||||||
implementation("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion")
|
implementation("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion")
|
||||||
implementation("io.ktor:ktor-server-core:$ktorVersion")
|
implementation("io.ktor:ktor-server-core:$ktorVersion")
|
||||||
|
@ -31,27 +29,21 @@ dependencies {
|
||||||
implementation("io.ktor:ktor-server-sessions:$ktorVersion")
|
implementation("io.ktor:ktor-server-sessions:$ktorVersion")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0")
|
||||||
implementation("ch.qos.logback:logback-classic:1.2.3")
|
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"
|
description = "twigs-server"
|
||||||
|
|
||||||
val twigsMain = "com.wbrawner.twigs.server.TwigsServerApplication"
|
val twigsMain = "com.wbrawner.twigs.server.ApplicationKt"
|
||||||
|
|
||||||
application {
|
application {
|
||||||
mainClass.set(twigsMain)
|
mainClass.set(twigsMain)
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.bootJar {
|
tasks.shadowJar {
|
||||||
mainClassName = twigsMain
|
manifest {
|
||||||
}
|
attributes("Main-Class" to twigsMain)
|
||||||
|
archiveBaseName.set("twigs")
|
||||||
tasks.bootRun {
|
archiveClassifier.set("")
|
||||||
mainClass.set(twigsMain)
|
archiveVersion.set("")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,16 @@
|
||||||
package com.wbrawner.twigs.server
|
package com.wbrawner.twigs.server
|
||||||
|
|
||||||
import com.wbrawner.twigs.*
|
import com.wbrawner.twigs.*
|
||||||
import com.wbrawner.twigs.model.Transaction
|
import com.wbrawner.twigs.db.*
|
||||||
import com.wbrawner.twigs.storage.*
|
import com.wbrawner.twigs.storage.*
|
||||||
|
import com.zaxxer.hikari.HikariConfig
|
||||||
|
import com.zaxxer.hikari.HikariDataSource
|
||||||
import io.ktor.application.*
|
import io.ktor.application.*
|
||||||
import io.ktor.auth.*
|
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 io.ktor.sessions.*
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.delay
|
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)
|
fun main(args: Array<String>): Unit = io.ktor.server.cio.EngineMain.main(args)
|
||||||
|
|
||||||
|
private const val DATABASE_VERSION = 1
|
||||||
|
|
||||||
@ExperimentalTime
|
@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,
|
budgetRepository: BudgetRepository,
|
||||||
categoryRepository: CategoryRepository,
|
categoryRepository: CategoryRepository,
|
||||||
permissionRepository: PermissionRepository,
|
permissionRepository: PermissionRepository,
|
||||||
|
@ -24,27 +58,57 @@ fun Application.module(
|
||||||
transactionRepository: TransactionRepository,
|
transactionRepository: TransactionRepository,
|
||||||
userRepository: UserRepository
|
userRepository: UserRepository
|
||||||
) {
|
) {
|
||||||
install(Sessions) {
|
install(CallLogging)
|
||||||
header<String>("Authorization")
|
|
||||||
}
|
|
||||||
install(Authentication) {
|
install(Authentication) {
|
||||||
session<String> {
|
session<Session> {
|
||||||
validate { token ->
|
challenge {
|
||||||
val session = sessionRepository.findAll(token).firstOrNull()
|
call.respond(HttpStatusCode.Unauthorized)
|
||||||
?: return@validate null
|
}
|
||||||
return@validate if (twoWeeksFromNow.after(session.expiration)) {
|
validate { session ->
|
||||||
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 {
|
} else {
|
||||||
null
|
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)
|
budgetRoutes(budgetRepository, permissionRepository)
|
||||||
categoryRoutes(categoryRepository, permissionRepository)
|
categoryRoutes(categoryRepository, permissionRepository)
|
||||||
transactionRoutes(transactionRepository, permissionRepository)
|
transactionRoutes(transactionRepository, permissionRepository)
|
||||||
userRoutes(permissionRepository, sessionRepository, userRepository)
|
userRoutes(permissionRepository, sessionRepository, userRepository)
|
||||||
launch {
|
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) {
|
while (currentCoroutineContext().isActive) {
|
||||||
delay(Duration.hours(24))
|
delay(Duration.hours(24))
|
||||||
sessionRepository.deleteExpired()
|
sessionRepository.deleteExpired()
|
||||||
|
|
|
@ -11,6 +11,8 @@ ktor {
|
||||||
host = ${?TWIGS_DB_HOST}
|
host = ${?TWIGS_DB_HOST}
|
||||||
port = 5432
|
port = 5432
|
||||||
port = ${?TWIGS_DB_PORT}
|
port = ${?TWIGS_DB_PORT}
|
||||||
|
name = twigs
|
||||||
|
name = ${?TWIGS_DB_NAME}
|
||||||
user = twigs
|
user = twigs
|
||||||
user = ${?TWIGS_DB_USER}
|
user = ${?TWIGS_DB_USER}
|
||||||
password = twigs
|
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 {
|
repositories {
|
||||||
mavenLocal()
|
mavenLocal()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
maven { url = java.net.URI("https://repo.spring.io/snapshot") }
|
|
||||||
maven { url = java.net.URI("https://repo.spring.io/milestone") }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
|
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"
|
group = "com.wbrawner"
|
||||||
version = "0.0.1-SNAPSHOT"
|
version = "0.0.1-SNAPSHOT"
|
||||||
tasks.withType<KotlinCompile> {
|
tasks.withType<KotlinCompile> {
|
||||||
kotlinOptions.jvmTarget = "14"
|
kotlinOptions.jvmTarget = "16"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,8 @@ val ktorVersion: String by rootProject.extra
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(kotlin("stdlib"))
|
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")
|
testImplementation("org.junit.jupiter:junit-jupiter-api:5.6.0")
|
||||||
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
|
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
|
package com.wbrawner.twigs
|
||||||
|
|
||||||
|
import at.favre.lib.crypto.bcrypt.BCrypt
|
||||||
|
import java.time.Instant
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
private val CALENDAR_FIELDS = intArrayOf(
|
private val CALENDAR_FIELDS = intArrayOf(
|
||||||
|
@ -10,26 +12,26 @@ private val CALENDAR_FIELDS = intArrayOf(
|
||||||
Calendar.DATE
|
Calendar.DATE
|
||||||
)
|
)
|
||||||
|
|
||||||
val firstOfMonth: Date
|
val firstOfMonth: Instant
|
||||||
get() = GregorianCalendar(TimeZone.getTimeZone("UTC")).run {
|
get() = GregorianCalendar(TimeZone.getTimeZone("UTC")).run {
|
||||||
for (calField in CALENDAR_FIELDS) {
|
for (calField in CALENDAR_FIELDS) {
|
||||||
set(calField, getActualMinimum(calField))
|
set(calField, getActualMinimum(calField))
|
||||||
}
|
}
|
||||||
time
|
toInstant()
|
||||||
}
|
}
|
||||||
|
|
||||||
val endOfMonth: Date
|
val endOfMonth: Instant
|
||||||
get() = GregorianCalendar(TimeZone.getTimeZone("UTC")).run {
|
get() = GregorianCalendar(TimeZone.getTimeZone("UTC")).run {
|
||||||
for (calField in CALENDAR_FIELDS) {
|
for (calField in CALENDAR_FIELDS) {
|
||||||
set(calField, getActualMaximum(calField))
|
set(calField, getActualMaximum(calField))
|
||||||
}
|
}
|
||||||
time
|
toInstant()
|
||||||
}
|
}
|
||||||
|
|
||||||
val twoWeeksFromNow: Date
|
val twoWeeksFromNow: Instant
|
||||||
get() = GregorianCalendar(TimeZone.getTimeZone("UTC")).run {
|
get() = GregorianCalendar(TimeZone.getTimeZone("UTC")).run {
|
||||||
add(Calendar.DATE, 14)
|
add(Calendar.DATE, 14)
|
||||||
time
|
toInstant()
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
private const val CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
@ -42,5 +44,5 @@ fun randomString(length: Int = 32): String {
|
||||||
return id.toString()
|
return id.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Use bcrypt to hash strings
|
lateinit var salt: String
|
||||||
fun String.hash(): String = this
|
fun String.hash(): String = String(BCrypt.withDefaults().hash(10, salt.toByteArray(), this.toByteArray()))
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
package com.wbrawner.twigs.model
|
package com.wbrawner.twigs.model
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.Identifiable
|
||||||
import com.wbrawner.twigs.randomString
|
import com.wbrawner.twigs.randomString
|
||||||
|
|
||||||
data class Budget(
|
data class Budget(
|
||||||
var id: String = randomString(),
|
override val id: String = randomString(),
|
||||||
var name: String? = null,
|
var name: String? = null,
|
||||||
var description: String? = null,
|
var description: String? = null,
|
||||||
var currencyCode: String? = "USD",
|
var currencyCode: String? = "USD",
|
||||||
)
|
) : Identifiable
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
package com.wbrawner.twigs.model
|
package com.wbrawner.twigs.model
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.Identifiable
|
||||||
import com.wbrawner.twigs.randomString
|
import com.wbrawner.twigs.randomString
|
||||||
|
|
||||||
data class Category(
|
data class Category(
|
||||||
val id: String = randomString(),
|
override val id: String = randomString(),
|
||||||
var title: String = "",
|
val title: String,
|
||||||
var description: String? = null,
|
val amount: Long,
|
||||||
var amount: Long = 0L,
|
val budgetId: String,
|
||||||
var budgetId: String? = null,
|
val description: String? = null,
|
||||||
var expense: Boolean = true,
|
val expense: Boolean = true,
|
||||||
var archived: Boolean = false
|
val archived: Boolean = false
|
||||||
)
|
) : Identifiable
|
||||||
|
|
|
@ -1,16 +1,17 @@
|
||||||
package com.wbrawner.twigs.model
|
package com.wbrawner.twigs.model
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.Identifiable
|
||||||
import com.wbrawner.twigs.randomString
|
import com.wbrawner.twigs.randomString
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
data class Transaction(
|
data class Transaction(
|
||||||
val id: String = randomString(),
|
override val id: String = randomString(),
|
||||||
val title: String? = null,
|
val title: String,
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
val date: Instant? = null,
|
val date: Instant,
|
||||||
val amount: Long? = null,
|
val amount: Long,
|
||||||
val categoryId: String? = null,
|
val expense: Boolean,
|
||||||
val expense: Boolean? = null,
|
|
||||||
val createdBy: String,
|
val createdBy: String,
|
||||||
|
val categoryId: String? = null,
|
||||||
val budgetId: String
|
val budgetId: String
|
||||||
)
|
) : Identifiable
|
||||||
|
|
|
@ -1,13 +1,15 @@
|
||||||
package com.wbrawner.twigs.model
|
package com.wbrawner.twigs.model
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.Identifiable
|
||||||
import com.wbrawner.twigs.randomString
|
import com.wbrawner.twigs.randomString
|
||||||
|
import io.ktor.auth.*
|
||||||
|
|
||||||
data class User(
|
data class User(
|
||||||
val id: String = randomString(),
|
override val id: String = randomString(),
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
val password: String = "",
|
val password: String = "",
|
||||||
val email: String? = null
|
val email: String? = null
|
||||||
)
|
) : Principal, Identifiable
|
||||||
|
|
||||||
enum class Permission {
|
enum class Permission {
|
||||||
/**
|
/**
|
||||||
|
@ -33,10 +35,6 @@ enum class Permission {
|
||||||
fun isAtLeast(wanted: Permission): Boolean {
|
fun isAtLeast(wanted: Permission): Boolean {
|
||||||
return ordinal >= wanted.ordinal
|
return ordinal >= wanted.ordinal
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isNotAtLeast(wanted: Permission): Boolean {
|
|
||||||
return ordinal < wanted.ordinal
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class UserPermission(
|
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:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
environment:
|
environment:
|
||||||
SPRING_DATASOURCE_URL: "jdbc:mysql://db:3306/budget?useSSL=false"
|
TWIGS_DB_HOST: db
|
||||||
SPRING_JPA_HIBERNATE_DDL-AUTO: update
|
|
||||||
SERVER_TOMCAT_MAX-THREADS: 5
|
|
||||||
TWIGS_CORS_DOMAINS: "http://localhost:4200"
|
|
||||||
networks:
|
networks:
|
||||||
- twigs
|
- twigs
|
||||||
command: sh -c "sleep 5 && /opt/java/openjdk/bin/java $JVM_ARGS -jar /twigs-api.jar"
|
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: mysql:5.7
|
image: postgres:13
|
||||||
ports:
|
ports:
|
||||||
- "3306:3306"
|
- "5432:5432"
|
||||||
environment:
|
environment:
|
||||||
MYSQL_RANDOM_ROOT_PASSWORD: "yes"
|
POSTGRES_DB: twigs
|
||||||
MYSQL_DATABASE: budget
|
POSTGRES_USER: twigs
|
||||||
MYSQL_USER: budget
|
POSTGRES_PASSWORD: twigs
|
||||||
MYSQL_PASSWORD: budget
|
|
||||||
networks:
|
networks:
|
||||||
- twigs
|
- twigs
|
||||||
hostname: db
|
hostname: db
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
rootProject.name = "twigs"
|
rootProject.name = "twigs"
|
||||||
include("core", "api", "app")
|
include("core", "api", "app")
|
||||||
include("storage")
|
include("storage")
|
||||||
|
include("db")
|
||||||
|
|
|
@ -7,10 +7,8 @@ val ktorVersion: String by rootProject.extra
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(kotlin("stdlib"))
|
implementation(kotlin("stdlib"))
|
||||||
implementation(project(":core"))
|
api(project(":core"))
|
||||||
api("io.ktor:ktor-auth:$ktorVersion")
|
|
||||||
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0")
|
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")
|
testImplementation("org.junit.jupiter:junit-jupiter-api:5.6.0")
|
||||||
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
|
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,8 +4,8 @@ import com.wbrawner.twigs.model.Category
|
||||||
|
|
||||||
interface CategoryRepository : Repository<Category> {
|
interface CategoryRepository : Repository<Category> {
|
||||||
fun findAll(
|
fun findAll(
|
||||||
|
budgetIds: List<String>,
|
||||||
ids: List<String>? = null,
|
ids: List<String>? = null,
|
||||||
budgetIds: List<String>? = null,
|
|
||||||
expense: Boolean? = null,
|
expense: Boolean? = null,
|
||||||
archived: Boolean? = null
|
archived: Boolean? = null
|
||||||
): List<Category>
|
): List<Category>
|
||||||
|
|
|
@ -6,8 +6,7 @@ package com.wbrawner.twigs.storage
|
||||||
* @param T The type of the object supported by this repository
|
* @param T The type of the object supported by this repository
|
||||||
*/
|
*/
|
||||||
interface Repository<T> {
|
interface Repository<T> {
|
||||||
suspend fun findAll(): List<T>
|
suspend fun findAll(ids: List<String>? = null): List<T>
|
||||||
suspend fun findAllByIds(id: List<String>): List<T>
|
|
||||||
suspend fun save(item: T): T
|
suspend fun save(item: T): T
|
||||||
suspend fun delete(item: T): Boolean
|
suspend fun delete(item: T): Boolean
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
package com.wbrawner.twigs.storage
|
package com.wbrawner.twigs.storage
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.Identifiable
|
||||||
import com.wbrawner.twigs.randomString
|
import com.wbrawner.twigs.randomString
|
||||||
import com.wbrawner.twigs.twoWeeksFromNow
|
import com.wbrawner.twigs.twoWeeksFromNow
|
||||||
import io.ktor.auth.*
|
import io.ktor.auth.*
|
||||||
import java.util.*
|
import java.time.Instant
|
||||||
|
|
||||||
data class Session(
|
data class Session(
|
||||||
|
override val id: String = randomString(),
|
||||||
val userId: String = "",
|
val userId: String = "",
|
||||||
val id: String = randomString(),
|
|
||||||
val token: String = randomString(255),
|
val token: String = randomString(255),
|
||||||
var expiration: Date = twoWeeksFromNow
|
var expiration: Instant = twoWeeksFromNow
|
||||||
) : Principal
|
) : Principal, Identifiable
|
||||||
|
|
|
@ -4,16 +4,9 @@ import com.wbrawner.twigs.model.User
|
||||||
|
|
||||||
interface UserRepository : Repository<User> {
|
interface UserRepository : Repository<User> {
|
||||||
fun findAll(
|
fun findAll(
|
||||||
ids: List<String>? = null,
|
nameOrEmail: String,
|
||||||
): List<User>
|
password: String
|
||||||
|
|
||||||
fun find(
|
|
||||||
name: String? = null,
|
|
||||||
email: String? = null,
|
|
||||||
password: String? = null
|
|
||||||
): List<User>
|
): List<User>
|
||||||
|
|
||||||
fun findAll(nameLike: String): List<User>
|
fun findAll(nameLike: String): List<User>
|
||||||
|
|
||||||
fun deleteById(id: String): Boolean
|
|
||||||
}
|
}
|
Loading…
Reference in a new issue