Skip to content

Commit

Permalink
/users HTTP endpoint + async transactions
Browse files Browse the repository at this point in the history
Async transactions require tests to be suspended to, it requires the coroutine-test package.

Stackoverflow API uses multiple parameters for queries, I implemented some subset of them
  • Loading branch information
asm0dey committed May 10, 2023
1 parent 7d8d4ba commit 05966d1
Show file tree
Hide file tree
Showing 6 changed files with 101 additions and 20 deletions.
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ dependencies {
testImplementation(libs.ktor.server.tests.jvm)
testImplementation(libs.testcontainers.postgres)
testImplementation(libs.testcontainers.jupiter)
testImplementation(libs.kotlinx.coroutines.test)
}

tasks.test {
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ liquibase = "org.liquibase:liquibase-core:4.21.1"

postgres = "org.postgresql:postgresql:42.6.0"

kotlinx-coroutines-test="org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.0"

kotlin-scripting-compiler-embeddable = { group = "org.jetbrains.kotlin", name = "kotlin-scripting-compiler-embeddable", version.ref = "kotlin" }

kotlin-serialization = { group = "org.jetbrains.kotlin", name = "kotlin-serialization", version.ref = "kotlin" }
Expand Down
12 changes: 10 additions & 2 deletions src/main/kotlin/io/ktor/answers/Application.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,32 @@ package io.ktor.answers

import io.ktor.answers.plugins.*
import io.ktor.server.application.*
import liquibase.Liquibase
import liquibase.Scope
import liquibase.command.CommandScope
import liquibase.command.core.UpdateCommandStep.CHANGELOG_FILE_ARG
import liquibase.command.core.UpdateCommandStep.COMMAND_NAME
import liquibase.command.core.helpers.DbUrlConnectionCommandStep.DATABASE_ARG
import liquibase.database.DatabaseFactory
import liquibase.database.jvm.JdbcConnection
import liquibase.resource.ClassLoaderResourceAccessor
import org.jetbrains.exposed.sql.Database
import java.sql.DriverManager


fun Application.module() {
migrateDb()
connectToDb()
configureSerialization()
configureRouting()
}

fun Application.connectToDb() = with(environment.config){
Database.connect(
property("database.url").getString(),
user = property("database.username").getString(),
password = property("database.password").getString()
)
}

fun Application.migrateDb() = with(environment.config) {
migrate(
property("database.url").getString(),
Expand Down
48 changes: 38 additions & 10 deletions src/main/kotlin/io/ktor/answers/db/UserRepository.kt
Original file line number Diff line number Diff line change
@@ -1,21 +1,47 @@
package io.ktor.answers.db

import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import io.ktor.answers.plugins.*
import kotlinx.coroutines.Dispatchers
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SortOrder.ASC
import org.jetbrains.exposed.sql.kotlin.datetime.date
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction

object UserRepository {
fun allUsers(): List<User> {
return transaction { User.find { UserTable.active eq true }.notForUpdate().toList() }
class UserRepository {
private val defaultQueryParams = CommonQueryParams(0, 20, null, null)
suspend fun allUsers(
parsed: CommonQueryParams = defaultQueryParams,
sortBy: String = "name",
order: SortOrder = ASC
): List<User> = asyncTransaction {
User
.find {
val active = UserTable.active eq true
val from = if (parsed.fromDate != null) (UserTable.createdAt.date() greaterEq parsed.fromDate) else null
val to = if (parsed.toDate != null) UserTable.createdAt.date() lessEq parsed.toDate else null
sequenceOf(active, to, from).filterNotNull().reduce { acc, op -> acc and op }
}
.apply {
if (parsed.page != null) limit(parsed.pageSize, parsed.pageSize.toLong() * (parsed.page - 1))
else limit(parsed.pageSize)
}
.orderBy(
when (sortBy) {
"name" -> UserTable.name
"creation" -> UserTable.createdAt
else -> error("Unsupported sort column: $sortBy")
} to order
)
.toList()
}

fun userById(id: Long): User? = transaction {
suspend fun userById(id: Long): User? = asyncTransaction {
User
.find { (UserTable.id eq id) and (UserTable.active eq true) }
.limit(1).firstOrNull()
}

fun userAnswers(userId: Long) = transaction {
suspend fun userAnswers(userId: Long): List<Answer> = asyncTransaction {
Answer
.wrapRows(
UserTable
Expand All @@ -27,7 +53,7 @@ object UserRepository {
.toList()
}

fun userComments(userId: Long) = transaction {
suspend fun userComments(userId: Long): List<Comment> = asyncTransaction {
Comment
.wrapRows(
CommentTable
Expand All @@ -39,7 +65,7 @@ object UserRepository {
.toList()
}

fun userQuestions(userId: Long) = transaction {
suspend fun userQuestions(userId: Long): List<Question> = asyncTransaction {
Question
.wrapRows(
QuestionTable
Expand All @@ -52,3 +78,5 @@ object UserRepository {
}
}

suspend fun <T> asyncTransaction(block: Transaction.() -> T): T =
newSuspendedTransaction(Dispatchers.IO, statement = block)
36 changes: 36 additions & 0 deletions src/main/kotlin/io/ktor/answers/plugins/Routing.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package io.ktor.answers.plugins

import io.ktor.answers.db.*
import io.ktor.server.routing.*
import io.ktor.server.response.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.http.*
import io.ktor.server.application.*
import kotlinx.datetime.LocalDate
import kotlinx.datetime.toLocalDate
import org.jetbrains.exposed.sql.SortOrder

fun Application.configureRouting() {
install(StatusPages) {
Expand All @@ -16,5 +20,37 @@ fun Application.configureRouting() {
get("/") {
call.respondText("Hello World!")
}
route("/users") {
get {
val queryParams = call.request.queryParameters
queryParams.parsed()
val sortBy = queryParams["sortBy"] ?: "name"
val order = (queryParams["order"] ?: "asc").toSortOrder()

call.respond(UserRepository().allUsers(queryParams.parsed(), sortBy, order))
}
}
}
}

private fun String.toSortOrder(): SortOrder = when (this) {
"asc" -> SortOrder.ASC
"desc" -> SortOrder.DESC
else -> error("Unsupported sort order: $this")
}

private fun Parameters.parsed(): CommonQueryParams {
val page = this["page"]?.toInt()
val pageSize = this["pagesize"]?.toInt() ?: 20
val fromDate = this["fromdate"]?.toLocalDate()
val toDate = this["todate"]?.toLocalDate()
return CommonQueryParams(page, pageSize, fromDate, toDate)
}

data class CommonQueryParams(
val page: Int?,
val pageSize: Int,
val fromDate: LocalDate?,
val toDate: LocalDate?,
)

22 changes: 14 additions & 8 deletions src/test/kotlin/io/ktor/answers/DbTest.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package io.ktor.answers

import io.ktor.answers.db.*
import kotlinx.coroutines.test.runTest
import org.jetbrains.exposed.sql.deleteAll
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.jetbrains.exposed.sql.transactions.transaction
import org.testcontainers.junit.jupiter.Testcontainers
import kotlin.random.Random
Expand All @@ -10,11 +13,12 @@ import kotlin.test.assertEquals

@Testcontainers
class DbTest : AbstractDbTest() {
private val userRepository = UserRepository()

@Test
fun `deactivated users should not be in the 'all users' response`() {
val current = UserRepository.allUsers().size
transaction {
fun `deactivated users should not be in the 'all users' response`() = runTest {
val current = userRepository.allUsers().size
newSuspendedTransaction {
User.new {
name = Random.nextString(7)
email = Random.email()
Expand All @@ -23,13 +27,14 @@ class DbTest : AbstractDbTest() {
displayName = Random.nextString(7)
}
}
assertEquals(current, UserRepository.allUsers().size)
assertEquals(current, userRepository.allUsers().size)
newSuspendedTransaction { UserTable.deleteAll() }
}

@Test
fun `insertion of an active user increases number of users in DB by 1`() {
val current = UserRepository.allUsers().size
transaction {
fun `insertion of an active user increases number of users in DB by 1`() = runTest {
val current = userRepository.allUsers().size
newSuspendedTransaction {
User.new {
name = Random.nextString(7)
email = Random.email()
Expand All @@ -38,7 +43,8 @@ class DbTest : AbstractDbTest() {
displayName = Random.nextString(7)
}
}
assertEquals(current + 1, UserRepository.allUsers().size)
assertEquals(current + 1, userRepository.allUsers().size)
newSuspendedTransaction { UserTable.deleteAll() }
}
}

Expand Down

0 comments on commit 05966d1

Please sign in to comment.