WIP: Finish ktor migration

This commit is contained in:
William Brawner 2021-08-12 15:22:06 -06:00
parent 9fc3d1ac1c
commit 4ef2fed502
43 changed files with 822 additions and 193 deletions

View file

@ -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

View file

@ -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")

View file

@ -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(

View file

@ -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)

View file

@ -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()) {

View file

@ -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
)

View file

@ -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

View file

@ -1,3 +1,6 @@
package com.wbrawner.twigs
import kotlinx.serialization.Serializable
@Serializable
data class ErrorResponse(val message: String)

View file

@ -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(

View file

@ -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()) {

View file

@ -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())

View file

@ -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)
}
}

View file

@ -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("")
}
}

View file

@ -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()

View file

@ -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

View file

@ -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=*

View file

@ -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"
}
}

View file

@ -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")
}

View file

@ -0,0 +1,5 @@
package com.wbrawner.twigs
interface Identifiable {
val id: String
}

View file

@ -0,0 +1,5 @@
package com.wbrawner.twigs
interface Logger {
fun log(message: String)
}

View file

@ -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()))

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
View file

@ -0,0 +1 @@
build/

20
db/build.gradle.kts Normal file
View 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()
}

View file

@ -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)
)

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View 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)

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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

View file

@ -1,3 +1,4 @@
rootProject.name = "twigs"
include("core", "api", "app")
include("storage")
include("db")

View file

@ -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")
}

View file

@ -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>

View file

@ -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
}

View file

@ -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

View file

@ -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
}