From 4ef2fed5024734ec479e16ca20ad580d15a5f0cd Mon Sep 17 00:00:00 2001 From: William Brawner Date: Thu, 12 Aug 2021 15:22:06 -0600 Subject: [PATCH] WIP: Finish ktor migration --- Dockerfile | 11 +- api/build.gradle.kts | 2 + .../kotlin/com/wbrawner/twigs/ApiUtils.kt | 4 +- .../kotlin/com/wbrawner/twigs/BudgetApi.kt | 5 +- .../kotlin/com/wbrawner/twigs/BudgetRoutes.kt | 10 +- .../kotlin/com/wbrawner/twigs/CategoryApi.kt | 29 +++-- .../com/wbrawner/twigs/CategoryRoutes.kt | 69 +++++----- .../com/wbrawner/twigs/ErrorResponse.kt | 3 + .../com/wbrawner/twigs/TransactionApi.kt | 4 + .../com/wbrawner/twigs/TransactionRoutes.kt | 32 +++-- .../main/kotlin/com/wbrawner/twigs/UserApi.kt | 11 +- .../kotlin/com/wbrawner/twigs/UserRoutes.kt | 25 ++-- app/build.gradle.kts | 28 ++-- .../com/wbrawner/twigs/server/Application.kt | 86 +++++++++++-- app/src/main/resources/application.conf | 2 + app/src/main/resources/application.properties | 10 -- build.gradle.kts | 5 +- core/build.gradle.kts | 2 + .../kotlin/com/wbrawner/twigs/Identifiable.kt | 5 + .../main/kotlin/com/wbrawner/twigs/Logger.kt | 5 + .../main/kotlin/com/wbrawner/twigs/Utils.kt | 18 +-- .../kotlin/com/wbrawner/twigs/model/Budget.kt | 5 +- .../com/wbrawner/twigs/model/Category.kt | 17 +-- .../com/wbrawner/twigs/model/Transaction.kt | 15 ++- .../kotlin/com/wbrawner/twigs/model/User.kt | 10 +- db/.gitignore | 1 + db/build.gradle.kts | 20 +++ .../com/wbrawner/twigs/db/DatabaseMetadata.kt | 8 ++ .../wbrawner/twigs/db/JdbcBudgetRepository.kt | 31 +++++ .../twigs/db/JdbcCategoryRepository.kt | 65 ++++++++++ .../twigs/db/JdbcPermissionRepository.kt | 54 ++++++++ .../com/wbrawner/twigs/db/JdbcRepository.kt | 120 ++++++++++++++++++ .../twigs/db/JdbcSessionRepository.kt | 47 +++++++ .../twigs/db/JdbcTransactionRepository.kt | 110 ++++++++++++++++ .../wbrawner/twigs/db/JdbcUserRepository.kt | 45 +++++++ .../wbrawner/twigs/db/MetadataRepository.kt | 52 ++++++++ docker-compose.yml | 17 +-- settings.gradle.kts | 1 + storage/build.gradle.kts | 4 +- .../twigs/storage/CategoryRepository.kt | 2 +- .../com/wbrawner/twigs/storage/Repository.kt | 5 +- .../com/wbrawner/twigs/storage/Session.kt | 9 +- .../wbrawner/twigs/storage/UserRepository.kt | 11 +- 43 files changed, 822 insertions(+), 193 deletions(-) delete mode 100644 app/src/main/resources/application.properties create mode 100644 core/src/main/kotlin/com/wbrawner/twigs/Identifiable.kt create mode 100644 core/src/main/kotlin/com/wbrawner/twigs/Logger.kt create mode 100644 db/.gitignore create mode 100644 db/build.gradle.kts create mode 100644 db/src/main/kotlin/com/wbrawner/twigs/db/DatabaseMetadata.kt create mode 100644 db/src/main/kotlin/com/wbrawner/twigs/db/JdbcBudgetRepository.kt create mode 100644 db/src/main/kotlin/com/wbrawner/twigs/db/JdbcCategoryRepository.kt create mode 100644 db/src/main/kotlin/com/wbrawner/twigs/db/JdbcPermissionRepository.kt create mode 100644 db/src/main/kotlin/com/wbrawner/twigs/db/JdbcRepository.kt create mode 100644 db/src/main/kotlin/com/wbrawner/twigs/db/JdbcSessionRepository.kt create mode 100644 db/src/main/kotlin/com/wbrawner/twigs/db/JdbcTransactionRepository.kt create mode 100644 db/src/main/kotlin/com/wbrawner/twigs/db/JdbcUserRepository.kt create mode 100644 db/src/main/kotlin/com/wbrawner/twigs/db/MetadataRepository.kt diff --git a/Dockerfile b/Dockerfile index 123cfff..6d5273b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,7 @@ FROM openjdk:14-jdk as builder MAINTAINER William Brawner -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 diff --git a/api/build.gradle.kts b/api/build.gradle.kts index 409bcd6..c2b0ec1 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -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") diff --git a/api/src/main/kotlin/com/wbrawner/twigs/ApiUtils.kt b/api/src/main/kotlin/com/wbrawner/twigs/ApiUtils.kt index 42e280a..ef3ecbf 100644 --- a/api/src/main/kotlin/com/wbrawner/twigs/ApiUtils.kt +++ b/api/src/main/kotlin/com/wbrawner/twigs/ApiUtils.kt @@ -42,11 +42,11 @@ suspend fun PipelineContext.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.errorResponse( diff --git a/api/src/main/kotlin/com/wbrawner/twigs/BudgetApi.kt b/api/src/main/kotlin/com/wbrawner/twigs/BudgetApi.kt index 827d68d..c25911a 100644 --- a/api/src/main/kotlin/com/wbrawner/twigs/BudgetApi.kt +++ b/api/src/main/kotlin/com/wbrawner/twigs/BudgetApi.kt @@ -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? = 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) \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/twigs/BudgetRoutes.kt b/api/src/main/kotlin/com/wbrawner/twigs/BudgetRoutes.kt index b9d1fd1..16f0db3 100644 --- a/api/src/main/kotlin/com/wbrawner/twigs/BudgetRoutes.kt +++ b/api/src/main/kotlin/com/wbrawner/twigs/BudgetRoutes.kt @@ -20,10 +20,14 @@ fun Application.budgetRoutes( routing { route("/api/budgets") { authenticate(optional = false) { - get("/") { + get { val session = call.principal()!! val budgetIds = permissionRepository.findAll(userId = session.userId).map { it.budgetId } - val budgets = budgetRepository.findAllByIds(budgetIds).map { + if (budgetIds.isEmpty()) { + call.respond(emptyList()) + 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()!! val request = call.receive() if (request.name.isNullOrBlank()) { diff --git a/api/src/main/kotlin/com/wbrawner/twigs/CategoryApi.kt b/api/src/main/kotlin/com/wbrawner/twigs/CategoryApi.kt index 8990cce..18427fb 100644 --- a/api/src/main/kotlin/com/wbrawner/twigs/CategoryApi.kt +++ b/api/src/main/kotlin/com/wbrawner/twigs/CategoryApi.kt @@ -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) \ No newline at end of file +fun Category.asResponse(): CategoryResponse = CategoryResponse( + id, + title, + description, + amount, + budgetId, + expense, + archived +) \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/twigs/CategoryRoutes.kt b/api/src/main/kotlin/com/wbrawner/twigs/CategoryRoutes.kt index af55259..be8d475 100644 --- a/api/src/main/kotlin/com/wbrawner/twigs/CategoryRoutes.kt +++ b/api/src/main/kotlin/com/wbrawner/twigs/CategoryRoutes.kt @@ -19,29 +19,37 @@ fun Application.categoryRoutes( routing { route("/api/categories") { authenticate(optional = false) { - get("/") { + get { val session = call.principal()!! + val budgetIds = permissionRepository.findAll( + budgetIds = call.request.queryParameters.getAll("budgetIds"), + userId = session.userId + ).map { it.budgetId } + if (budgetIds.isEmpty()) { + call.respond(emptyList()) + return@get + } call.respond(categoryRepository.findAll( - budgetIds = permissionRepository.findAll( - budgetIds = call.request.queryParameters.getAll("budgetIds"), - userId = session.userId - ).map { it.budgetId }, + 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()!! + 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()!! val request = call.receive() 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, - ) + 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( - 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 - ) + categoryRepository.save( + 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, ) - ) + ).asResponse() ) } @@ -117,7 +122,7 @@ fun Application.categoryRoutes( requireBudgetWithPermission( permissionRepository, session.userId, - category.budgetId!!, + category.budgetId, Permission.WRITE ) { return@delete diff --git a/api/src/main/kotlin/com/wbrawner/twigs/ErrorResponse.kt b/api/src/main/kotlin/com/wbrawner/twigs/ErrorResponse.kt index 23613f6..4145f53 100644 --- a/api/src/main/kotlin/com/wbrawner/twigs/ErrorResponse.kt +++ b/api/src/main/kotlin/com/wbrawner/twigs/ErrorResponse.kt @@ -1,3 +1,6 @@ package com.wbrawner.twigs +import kotlinx.serialization.Serializable + +@Serializable data class ErrorResponse(val message: String) \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/twigs/TransactionApi.kt b/api/src/main/kotlin/com/wbrawner/twigs/TransactionApi.kt index 786f687..dc4e1de 100644 --- a/api/src/main/kotlin/com/wbrawner/twigs/TransactionApi.kt +++ b/api/src/main/kotlin/com/wbrawner/twigs/TransactionApi.kt @@ -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( diff --git a/api/src/main/kotlin/com/wbrawner/twigs/TransactionRoutes.kt b/api/src/main/kotlin/com/wbrawner/twigs/TransactionRoutes.kt index 95fa7c1..3baa20b 100644 --- a/api/src/main/kotlin/com/wbrawner/twigs/TransactionRoutes.kt +++ b/api/src/main/kotlin/com/wbrawner/twigs/TransactionRoutes.kt @@ -20,17 +20,18 @@ fun Application.transactionRoutes( routing { route("/api/transactions") { authenticate(optional = false) { - get("/") { + get { val session = call.principal()!! - call.respond(transactionRepository.findAll( - budgetIds = permissionRepository.findAll( - budgetIds = call.request.queryParameters.getAll("budgetIds"), - userId = session.userId - ).map { it.budgetId }, - categoryIds = call.request.queryParameters.getAll("categoryIds"), - from = call.request.queryParameters["from"]?.let { Instant.parse(it) }, - to = call.request.queryParameters["to"]?.let { Instant.parse(it) }, - expense = call.request.queryParameters["expense"]?.toBoolean(), + call.respond( + transactionRepository.findAll( + budgetIds = permissionRepository.findAll( + budgetIds = call.request.queryParameters.getAll("budgetIds"), + userId = session.userId + ).map { it.budgetId }, + categoryIds = call.request.queryParameters.getAll("categoryIds"), + from = call.request.queryParameters["from"]?.let { Instant.parse(it) }, + to = call.request.queryParameters["to"]?.let { Instant.parse(it) }, + expense = call.request.queryParameters["expense"]?.toBoolean(), ).map { it.asResponse() }) } @@ -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()!! val request = call.receive() if (request.title.isNullOrBlank()) { diff --git a/api/src/main/kotlin/com/wbrawner/twigs/UserApi.kt b/api/src/main/kotlin/com/wbrawner/twigs/UserApi.kt index e3f4483..4c6f8c1 100644 --- a/api/src/main/kotlin/com/wbrawner/twigs/UserApi.kt +++ b/api/src/main/kotlin/com/wbrawner/twigs/UserApi.kt @@ -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()) \ No newline at end of file +fun Session.asResponse(): SessionResponse = SessionResponse(userId, token, expiration.toString()) \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/twigs/UserRoutes.kt b/api/src/main/kotlin/com/wbrawner/twigs/UserRoutes.kt index 43607c1..db84fc0 100644 --- a/api/src/main/kotlin/com/wbrawner/twigs/UserRoutes.kt +++ b/api/src/main/kotlin/com/wbrawner/twigs/UserRoutes.kt @@ -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() 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() 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()!! // 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) } } diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b7c219a..94b5570 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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("") + } } diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt index 4b91504..b930a6e 100644 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt +++ b/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt @@ -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): 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("Authorization") - } + install(CallLogging) install(Authentication) { - session { - validate { token -> - val session = sessionRepository.findAll(token).firstOrNull() - ?: return@validate null - return@validate if (twoWeeksFromNow.after(session.expiration)) { - 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("Authorization") { + serializer = object : SessionSerializer { + 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() diff --git a/app/src/main/resources/application.conf b/app/src/main/resources/application.conf index 870e65c..10358bc 100644 --- a/app/src/main/resources/application.conf +++ b/app/src/main/resources/application.conf @@ -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 diff --git a/app/src/main/resources/application.properties b/app/src/main/resources/application.properties deleted file mode 100644 index 4c54847..0000000 --- a/app/src/main/resources/application.properties +++ /dev/null @@ -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=* diff --git a/build.gradle.kts b/build.gradle.kts index 4d3ff33..56d6022 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 { - kotlinOptions.jvmTarget = "14" + kotlinOptions.jvmTarget = "16" } } diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 4366218..beae8e0 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -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") } diff --git a/core/src/main/kotlin/com/wbrawner/twigs/Identifiable.kt b/core/src/main/kotlin/com/wbrawner/twigs/Identifiable.kt new file mode 100644 index 0000000..d3ad8e7 --- /dev/null +++ b/core/src/main/kotlin/com/wbrawner/twigs/Identifiable.kt @@ -0,0 +1,5 @@ +package com.wbrawner.twigs + +interface Identifiable { + val id: String +} \ No newline at end of file diff --git a/core/src/main/kotlin/com/wbrawner/twigs/Logger.kt b/core/src/main/kotlin/com/wbrawner/twigs/Logger.kt new file mode 100644 index 0000000..8789537 --- /dev/null +++ b/core/src/main/kotlin/com/wbrawner/twigs/Logger.kt @@ -0,0 +1,5 @@ +package com.wbrawner.twigs + +interface Logger { + fun log(message: String) +} \ No newline at end of file diff --git a/core/src/main/kotlin/com/wbrawner/twigs/Utils.kt b/core/src/main/kotlin/com/wbrawner/twigs/Utils.kt index fca5151..45c6d82 100644 --- a/core/src/main/kotlin/com/wbrawner/twigs/Utils.kt +++ b/core/src/main/kotlin/com/wbrawner/twigs/Utils.kt @@ -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())) diff --git a/core/src/main/kotlin/com/wbrawner/twigs/model/Budget.kt b/core/src/main/kotlin/com/wbrawner/twigs/model/Budget.kt index d82b204..e86686f 100644 --- a/core/src/main/kotlin/com/wbrawner/twigs/model/Budget.kt +++ b/core/src/main/kotlin/com/wbrawner/twigs/model/Budget.kt @@ -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 diff --git a/core/src/main/kotlin/com/wbrawner/twigs/model/Category.kt b/core/src/main/kotlin/com/wbrawner/twigs/model/Category.kt index 0c2eed3..2c91d52 100644 --- a/core/src/main/kotlin/com/wbrawner/twigs/model/Category.kt +++ b/core/src/main/kotlin/com/wbrawner/twigs/model/Category.kt @@ -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 diff --git a/core/src/main/kotlin/com/wbrawner/twigs/model/Transaction.kt b/core/src/main/kotlin/com/wbrawner/twigs/model/Transaction.kt index 3a17b43..04eaad3 100644 --- a/core/src/main/kotlin/com/wbrawner/twigs/model/Transaction.kt +++ b/core/src/main/kotlin/com/wbrawner/twigs/model/Transaction.kt @@ -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 diff --git a/core/src/main/kotlin/com/wbrawner/twigs/model/User.kt b/core/src/main/kotlin/com/wbrawner/twigs/model/User.kt index 18fb4e8..883786e 100644 --- a/core/src/main/kotlin/com/wbrawner/twigs/model/User.kt +++ b/core/src/main/kotlin/com/wbrawner/twigs/model/User.kt @@ -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( diff --git a/db/.gitignore b/db/.gitignore new file mode 100644 index 0000000..d163863 --- /dev/null +++ b/db/.gitignore @@ -0,0 +1 @@ +build/ \ No newline at end of file diff --git a/db/build.gradle.kts b/db/build.gradle.kts new file mode 100644 index 0000000..019d548 --- /dev/null +++ b/db/build.gradle.kts @@ -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") { + useJUnitPlatform() +} \ No newline at end of file diff --git a/db/src/main/kotlin/com/wbrawner/twigs/db/DatabaseMetadata.kt b/db/src/main/kotlin/com/wbrawner/twigs/db/DatabaseMetadata.kt new file mode 100644 index 0000000..f7f798f --- /dev/null +++ b/db/src/main/kotlin/com/wbrawner/twigs/db/DatabaseMetadata.kt @@ -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) +) \ No newline at end of file diff --git a/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcBudgetRepository.kt b/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcBudgetRepository.kt new file mode 100644 index 0000000..ab2ac17 --- /dev/null +++ b/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcBudgetRepository.kt @@ -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(dataSource), + BudgetRepository { + override val tableName: String = TABLE_BUDGET + override val fields: Map Any?> = Fields.values().associateWith { it.entityField } + override val conflictFields: Collection = 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" + } +} + diff --git a/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcCategoryRepository.kt b/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcCategoryRepository.kt new file mode 100644 index 0000000..ee18e5a --- /dev/null +++ b/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcCategoryRepository.kt @@ -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(dataSource), CategoryRepository { + override val tableName: String = TABLE_CATEGORY + override val fields: Map Any?> = Fields.values().associateWith { it.entityField } + override val conflictFields: Collection = listOf(ID) + + override fun findAll( + budgetIds: List, + ids: List?, + expense: Boolean?, + archived: Boolean? + ): List = 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() + 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" + } +} + diff --git a/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcPermissionRepository.kt b/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcPermissionRepository.kt new file mode 100644 index 0000000..5e38581 --- /dev/null +++ b/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcPermissionRepository.kt @@ -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(dataSource), PermissionRepository { + override val tableName: String = TABLE_PERMISSIONS + override val fields: Map Any?> = Fields.values().associateWith { it.entityField } + override val conflictFields: Collection = + listOf(Fields.USER_ID.name.lowercase(), Fields.BUDGET_ID.name.lowercase()) + + override fun findAll(budgetIds: List?, userId: String?): List = + 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() + 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" + } +} + diff --git a/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcRepository.kt b/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcRepository.kt new file mode 100644 index 0000000..9010bf7 --- /dev/null +++ b/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcRepository.kt @@ -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>(protected val dataSource: DataSource) : + Repository { + abstract val tableName: String + abstract val fields: Map Any?> + abstract val conflictFields: Collection + val logger = LoggerFactory.getLogger(this::class.java) + + override suspend fun findAll(ids: List?): List = 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() + 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 { + val entities = mutableListOf() + while (next()) { + entities.add(toEntity()) + } + return entities + } + + abstract fun ResultSet.toEntity(): Entity + + protected fun Connection.executeQuery(sql: String, params: List) = prepareStatement(sql) + .apply { + logger.debug("QUERY: $sql\nPARAMS: ${params.joinToString(", ")}") + } + .setParameters(params) + .executeQuery() + .toEntityList() + + protected fun Connection.executeUpdate(sql: String, params: List = emptyList()) = prepareStatement(sql) + .apply { + logger.debug("QUERY: $sql\nPARAMS: ${params.joinToString(", ")}") + } + .setParameters(params) + .executeUpdate() + + fun PreparedStatement.setParameters(params: Iterable): 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 Collection.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) diff --git a/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcSessionRepository.kt b/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcSessionRepository.kt new file mode 100644 index 0000000..6b69725 --- /dev/null +++ b/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcSessionRepository.kt @@ -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(dataSource), + SessionRepository { + override val tableName: String = TABLE_SESSION + override val fields: Map Any?> = Fields.values().associateWith { it.entityField } + override val conflictFields: Collection = listOf(ID) + + override fun findAll(token: String): List = 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" + } +} + + diff --git a/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcTransactionRepository.kt b/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcTransactionRepository.kt new file mode 100644 index 0000000..ed985e6 --- /dev/null +++ b/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcTransactionRepository.kt @@ -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(dataSource), TransactionRepository { + override val tableName: String = TABLE_TRANSACTION + override val fields: Map Any?> = Fields.values().associateWith { it.entityField } + override val conflictFields: Collection = listOf(ID) + + override fun findAll( + ids: List?, + budgetIds: List?, + categoryIds: List?, + expense: Boolean?, + from: Instant?, + to: Instant? + ): List = dataSource.connection.use { conn -> + val sql = StringBuilder("SELECT * FROM $tableName") + val params = mutableListOf(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(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" + } +} + diff --git a/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcUserRepository.kt b/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcUserRepository.kt new file mode 100644 index 0000000..7e3fb41 --- /dev/null +++ b/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcUserRepository.kt @@ -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(dataSource), + UserRepository { + override val tableName: String = TABLE_USER + override val fields: Map Any?> = Fields.values().associateWith { it.entityField } + override val conflictFields: Collection = 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 = 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 = 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" + } +} + diff --git a/db/src/main/kotlin/com/wbrawner/twigs/db/MetadataRepository.kt b/db/src/main/kotlin/com/wbrawner/twigs/db/MetadataRepository.kt new file mode 100644 index 0000000..eae2829 --- /dev/null +++ b/db/src/main/kotlin/com/wbrawner/twigs/db/MetadataRepository.kt @@ -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(dataSource) { + override val tableName: String = TABLE_METADATA + override val fields: Map Any?> = Fields.values().associateWith { it.entityField } + override val conflictFields: Collection = 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?): List = 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" + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 4c8524a..3a0bde4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts index 46cc111..4eb59e4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,4 @@ rootProject.name = "twigs" include("core", "api", "app") include("storage") +include("db") diff --git a/storage/build.gradle.kts b/storage/build.gradle.kts index 215a0fb..69aea0c 100644 --- a/storage/build.gradle.kts +++ b/storage/build.gradle.kts @@ -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") } diff --git a/storage/src/main/kotlin/com/wbrawner/twigs/storage/CategoryRepository.kt b/storage/src/main/kotlin/com/wbrawner/twigs/storage/CategoryRepository.kt index 0adc36e..7a9be07 100644 --- a/storage/src/main/kotlin/com/wbrawner/twigs/storage/CategoryRepository.kt +++ b/storage/src/main/kotlin/com/wbrawner/twigs/storage/CategoryRepository.kt @@ -4,8 +4,8 @@ import com.wbrawner.twigs.model.Category interface CategoryRepository : Repository { fun findAll( + budgetIds: List, ids: List? = null, - budgetIds: List? = null, expense: Boolean? = null, archived: Boolean? = null ): List diff --git a/storage/src/main/kotlin/com/wbrawner/twigs/storage/Repository.kt b/storage/src/main/kotlin/com/wbrawner/twigs/storage/Repository.kt index feddcb8..b3023c4 100644 --- a/storage/src/main/kotlin/com/wbrawner/twigs/storage/Repository.kt +++ b/storage/src/main/kotlin/com/wbrawner/twigs/storage/Repository.kt @@ -6,8 +6,7 @@ package com.wbrawner.twigs.storage * @param T The type of the object supported by this repository */ interface Repository { - suspend fun findAll(): List - suspend fun findAllByIds(id: List): List + suspend fun findAll(ids: List? = null): List suspend fun save(item: T): T suspend fun delete(item: T): Boolean -} \ No newline at end of file +} diff --git a/storage/src/main/kotlin/com/wbrawner/twigs/storage/Session.kt b/storage/src/main/kotlin/com/wbrawner/twigs/storage/Session.kt index f4593ef..d73b905 100644 --- a/storage/src/main/kotlin/com/wbrawner/twigs/storage/Session.kt +++ b/storage/src/main/kotlin/com/wbrawner/twigs/storage/Session.kt @@ -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 diff --git a/storage/src/main/kotlin/com/wbrawner/twigs/storage/UserRepository.kt b/storage/src/main/kotlin/com/wbrawner/twigs/storage/UserRepository.kt index 7063a49..08e71f9 100644 --- a/storage/src/main/kotlin/com/wbrawner/twigs/storage/UserRepository.kt +++ b/storage/src/main/kotlin/com/wbrawner/twigs/storage/UserRepository.kt @@ -4,16 +4,9 @@ import com.wbrawner.twigs.model.User interface UserRepository : Repository { fun findAll( - ids: List? = null, - ): List - - fun find( - name: String? = null, - email: String? = null, - password: String? = null + nameOrEmail: String, + password: String ): List fun findAll(nameLike: String): List - - fun deleteById(id: String): Boolean } \ No newline at end of file