Cleaner code

This commit is contained in:
Hubert Van De Walle 2020-07-01 00:12:56 +02:00
parent 0993d93ccc
commit e03e12110b
31 changed files with 223 additions and 223 deletions

View File

@ -16,7 +16,6 @@ jwt:
validity: 1
unit: HOURS
refresh:
secret: ${JWT_REFRESH_SECRET=-wWchkx44YGig4Q5Z7b7+E/3ymGEGd6PS7UGedMul3bg=} # Can be generated with `openssl rand -base64 32`
validity: 15
unit: DAYS

21
api/src/Configuration.kt Normal file
View File

@ -0,0 +1,21 @@
package be.vandewalleh
import com.sksamuel.hoplite.Masked
import java.util.concurrent.TimeUnit
data class Config(val database: DatabaseConfig, val server: ServerConfig, val jwt: JwtConfig) {
override fun toString(): String {
return """
Config(
database=$database,
server=$server,
jwt=$jwt
)
""".trimIndent()
}
}
data class DatabaseConfig(val host: String, val port: Int, val name: String, val username: String, val password: Masked)
data class ServerConfig(val host: String, val port: Int, val cors: Boolean)
data class JwtConfig(val auth: Jwt, val refresh: Jwt)
data class Jwt(val validity: Long, val unit: TimeUnit, val secret: Masked)

View File

@ -1,24 +1,40 @@
package be.vandewalleh
import be.vandewalleh.features.BcryptPasswordHash
import be.vandewalleh.features.PasswordHash
import be.vandewalleh.features.configurationModule
import be.vandewalleh.migrations.Migration
import be.vandewalleh.services.serviceModule
import me.liuwj.ktorm.database.*
import be.vandewalleh.auth.AuthenticationModule
import be.vandewalleh.auth.SimpleJWT
import be.vandewalleh.extensions.ApplicationBuilder
import be.vandewalleh.factories.configurationFactory
import be.vandewalleh.factories.dataSourceFactory
import be.vandewalleh.factories.databaseFactory
import be.vandewalleh.factories.simpleJwtFactory
import be.vandewalleh.features.*
import be.vandewalleh.services.NoteService
import be.vandewalleh.services.UserService
import org.kodein.di.Kodein
import org.kodein.di.generic.bind
import org.kodein.di.generic.instance
import org.kodein.di.generic.singleton
import org.slf4j.Logger
import org.kodein.di.generic.*
import org.slf4j.LoggerFactory
import javax.sql.DataSource
val mainModule = Kodein.Module("main") {
import(serviceModule)
import(configurationModule)
bind<Logger>() with singleton { LoggerFactory.getLogger("Application") }
bind<Migration>() with singleton { Migration(this.kodein) }
bind<Database>() with singleton { Database.connect(this.instance<DataSource>()) }
bind() from singleton { NoteService(instance()) }
bind() from singleton { UserService(instance(), instance()) }
bind() from singleton { configurationFactory() }
bind() from setBinding<ApplicationBuilder>()
bind<ApplicationBuilder>().inSet() with singleton { ErrorHandler() }
bind<ApplicationBuilder>().inSet() with singleton { ContentNegotiationFeature() }
bind<ApplicationBuilder>().inSet() with singleton { CorsFeature(instance<Config>().server.cors) }
bind<ApplicationBuilder>().inSet() with singleton { AuthenticationModule(instance(tag = "auth")) }
bind<ApplicationBuilder>().inSet() with singleton { MigrationHook(instance()) }
bind<ApplicationBuilder>().inSet() with singleton { ShutdownDatabaseConnection(instance()) }
bind<SimpleJWT>(tag = "auth") with singleton { simpleJwtFactory(instance<Config>().jwt.auth) }
bind<SimpleJWT>(tag = "refresh") with singleton { simpleJwtFactory(instance<Config>().jwt.refresh) }
bind() from singleton { LoggerFactory.getLogger("Application") }
bind() from singleton { dataSourceFactory(instance<Config>().database) }
bind() from singleton { databaseFactory(instance()) }
bind() from singleton { Migration(instance()) }
bind<PasswordHash>() with singleton { BcryptPasswordHash() }
}

View File

@ -1,8 +1,6 @@
package be.vandewalleh
import be.vandewalleh.features.Config
import be.vandewalleh.features.loadFeatures
import be.vandewalleh.migrations.Migration
import be.vandewalleh.extensions.ApplicationBuilder
import be.vandewalleh.routing.noteRoutes
import be.vandewalleh.routing.tagsRoute
import be.vandewalleh.routing.userRoutes
@ -14,23 +12,20 @@ import io.ktor.server.netty.*
import org.kodein.di.Kodein
import org.kodein.di.description
import org.kodein.di.generic.instance
import org.kodein.di.generic.with
import org.slf4j.Logger
import java.util.concurrent.TimeUnit
fun main(args: Array<String>) {
fun main() {
val kodein = Kodein {
import(mainModule)
constant("config file") with "/application.prod.yaml" // FIXME
}
val config by kodein.instance<Config>()
val logger by kodein.instance<Logger>()
logger.info("Running application with configuration $config")
logger.debug("Kodein bindings\n${kodein.container.tree.bindings.description()}")
val migration by kodein.instance<Migration>()
migration.migrate()
serve(kodein)
}
@ -47,12 +42,19 @@ fun serve(kodein: Kodein) {
port = config.server.port
}
}
embeddedServer(Netty, env).start(wait = true)
with(embeddedServer(Netty, env)) {
addShutdownHook { stop(3, 5, TimeUnit.SECONDS) }
start(wait = true)
}
}
fun Application.module(kodein: Kodein) {
loadFeatures(kodein)
val builders: Set<ApplicationBuilder> by kodein.instance()
builders.forEach {
it.builder(this)
}
routing {
route("/user") {

View File

@ -1,19 +1,17 @@
package be.vandewalleh.auth
import be.vandewalleh.extensions.ApplicationBuilder
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.auth.jwt.*
import org.kodein.di.Kodein
import org.kodein.di.generic.instance
fun Application.authenticationModule(kodein: Kodein) {
class AuthenticationModule(authJwt: SimpleJWT) : ApplicationBuilder({
install(Authentication) {
jwt {
val simpleJwt by kodein.instance<SimpleJWT>(tag = "auth")
verifier(simpleJwt.verifier)
verifier(authJwt.verifier)
validate {
UserDbIdPrincipal(it.payload.getClaim("id").asInt())
}
UserIdPrincipal(it.payload.getClaim("id").asInt())
}
}
}
})

View File

@ -1,5 +0,0 @@
package be.vandewalleh.auth
import io.ktor.auth.Principal
data class UserDbIdPrincipal(val id: Int) : Principal

View File

@ -0,0 +1,9 @@
package be.vandewalleh.auth
import io.ktor.auth.*
/**
* Represents a simple user's principal identified by [id]
* @property id of the user
*/
data class UserIdPrincipal(val id: Int) : Principal

View File

@ -1,6 +1,6 @@
package be.vandewalleh.extensions
import be.vandewalleh.auth.UserDbIdPrincipal
import be.vandewalleh.auth.UserIdPrincipal
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.http.*
@ -17,4 +17,4 @@ suspend fun ApplicationCall.respondStatus(status: HttpStatusCode) {
/**
* @return the userId for the currently authenticated user
*/
fun ApplicationCall.authenticatedUserId() = principal<UserDbIdPrincipal>()!!.id
fun ApplicationCall.authenticatedUserId() = principal<UserIdPrincipal>()!!.id

View File

@ -1,11 +1,7 @@
package be.vandewalleh.extensions
import kotlinx.coroutines.*
fun <T> ioAsync(block: suspend CoroutineScope.() -> T): Deferred<T> {
return CoroutineScope(Dispatchers.IO).async(block = block)
}
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
suspend inline fun <T> launchIo(crossinline block: () -> T): T =
withContext(Dispatchers.IO) {

View File

@ -0,0 +1,8 @@
package be.vandewalleh.extensions
import io.ktor.application.*
import io.ktor.routing.*
abstract class RoutingBuilder(val builder: Routing.() -> Unit)
abstract class ApplicationBuilder(val builder: Application.() -> Unit)

View File

@ -0,0 +1,7 @@
package be.vandewalleh.factories
import be.vandewalleh.Config
import com.sksamuel.hoplite.ConfigLoader
fun configurationFactory() =
ConfigLoader().loadConfigOrThrow<Config>("/application.yaml")

View File

@ -0,0 +1,20 @@
package be.vandewalleh.factories
import be.vandewalleh.DatabaseConfig
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
fun dataSourceFactory(config: DatabaseConfig): HikariDataSource {
val host = config.host
val port = config.port
val name = config.name
val hikariConfig = HikariConfig().apply {
jdbcUrl = "jdbc:mariadb://$host:$port/$name"
username = config.username
password = config.password.value
connectionTimeout = 3000 // 3 seconds
}
return HikariDataSource(hikariConfig)
}

View File

@ -0,0 +1,6 @@
package be.vandewalleh.factories
import me.liuwj.ktorm.database.*
import javax.sql.DataSource
fun databaseFactory(dataSource: DataSource) = Database.connect(dataSource)

View File

@ -0,0 +1,6 @@
package be.vandewalleh.factories
import be.vandewalleh.Jwt
import be.vandewalleh.auth.SimpleJWT
fun simpleJwtFactory(jwt: Jwt) = SimpleJWT(jwt.secret.value, jwt.validity, jwt.unit)

View File

@ -1,62 +0,0 @@
package be.vandewalleh.features
import be.vandewalleh.auth.SimpleJWT
import com.sksamuel.hoplite.ConfigLoader
import com.sksamuel.hoplite.Masked
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import org.kodein.di.Kodein
import org.kodein.di.generic.bind
import org.kodein.di.generic.instance
import org.kodein.di.generic.singleton
import java.util.concurrent.TimeUnit
import javax.sql.DataSource
/**
* [Kodein] controller module containing the app configuration
*/
val configurationModule = Kodein.Module(name = "Configuration") {
bind() from singleton { ConfigLoader().loadConfigOrThrow<Config>("/application.yaml") }
bind<SimpleJWT>(tag = "auth") with singleton { configureAuthJwt(this.kodein) }
bind<SimpleJWT>(tag = "refresh") with singleton { configureRefreshJwt(this.kodein) }
bind<DataSource>() with singleton { configureDatasource(this.kodein) }
}
data class DatabaseConfig(val host: String, val port: Int, val name: String, val username: String, val password: Masked)
data class ServerConfig(val host: String, val port: Int, val cors: Boolean)
data class JwtConfig(val auth: Jwt, val refresh: Jwt)
data class Jwt(val validity: Long, val unit: TimeUnit, val secret: Masked)
data class Config(val database: DatabaseConfig, val server: ServerConfig, val jwt: JwtConfig)
private fun configureAuthJwt(kodein: Kodein): SimpleJWT {
val config by kodein.instance<Config>()
val jwtSecret = config.jwt.auth.secret
val authConfig = config.jwt.auth
return SimpleJWT(jwtSecret.value, authConfig.validity, authConfig.unit)
}
private fun configureRefreshJwt(kodein: Kodein): SimpleJWT {
val config by kodein.instance<Config>()
val jwtSecret = config.jwt.refresh.secret
val refreshConfig = config.jwt.auth
return SimpleJWT(jwtSecret.value, refreshConfig.validity, refreshConfig.unit)
}
private fun configureDatasource(kodein: Kodein): DataSource {
val config by kodein.instance<Config>()
val dbConfig = config.database
val host = dbConfig.host
val port = dbConfig.port
val name = dbConfig.name
val hikariConfig = HikariConfig().apply {
jdbcUrl = "jdbc:mariadb://$host:$port/$name"
username = dbConfig.username
password = dbConfig.password.value
connectionTimeout = 3000 // 3 seconds
}
return HikariDataSource(hikariConfig)
}

View File

@ -1,5 +1,6 @@
package be.vandewalleh.features
import be.vandewalleh.extensions.ApplicationBuilder
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.util.StdDateFormat
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
@ -8,7 +9,7 @@ import io.ktor.features.*
import io.ktor.jackson.*
import me.liuwj.ktorm.jackson.*
fun Application.contentNegotiationFeature() {
class ContentNegotiationFeature : ApplicationBuilder({
install(ContentNegotiation) {
jackson {
registerModule(KtormModule())
@ -17,4 +18,4 @@ fun Application.contentNegotiationFeature() {
dateFormat = StdDateFormat()
}
}
}
})

View File

@ -1,10 +1,12 @@
package be.vandewalleh.features
import be.vandewalleh.extensions.ApplicationBuilder
import io.ktor.application.*
import io.ktor.features.*
import io.ktor.http.*
fun Application.corsFeature() {
class CorsFeature(enabled: Boolean) : ApplicationBuilder({
if (enabled) {
install(CORS) {
anyHost()
header(HttpHeaders.ContentType)
@ -12,3 +14,4 @@ fun Application.corsFeature() {
methods.add(HttpMethod.Delete)
}
}
})

View File

@ -1,5 +1,6 @@
package be.vandewalleh.features
import be.vandewalleh.extensions.ApplicationBuilder
import io.ktor.application.*
import io.ktor.features.*
import io.ktor.http.*
@ -7,7 +8,8 @@ import io.ktor.response.*
import io.ktor.utils.io.errors.*
import java.sql.SQLTransientConnectionException
fun Application.handleErrors() {
class ErrorHandler : ApplicationBuilder({
install(StatusPages) {
jacksonErrors()
@ -24,7 +26,7 @@ fun Application.handleErrors() {
call.respond(HttpStatusCode.InternalServerError, error)
}
}
}
})
class ValidationException(val error: String) : RuntimeException()
class ErrorResponse(val error: String)

View File

@ -1,16 +0,0 @@
package be.vandewalleh.features
import be.vandewalleh.auth.authenticationModule
import io.ktor.application.*
import org.kodein.di.Kodein
import org.kodein.di.generic.instance
fun Application.loadFeatures(kodein: Kodein) {
val config by kodein.instance<Config>()
if (config.server.cors) {
corsFeature()
}
contentNegotiationFeature()
authenticationModule(kodein)
handleErrors()
}

View File

@ -0,0 +1,28 @@
package be.vandewalleh.features
import be.vandewalleh.extensions.ApplicationBuilder
import io.ktor.application.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.flywaydb.core.Flyway
import javax.sql.DataSource
class MigrationHook(migration: Migration) : ApplicationBuilder({
environment.monitor.subscribe(ApplicationStarted) {
CoroutineScope(Dispatchers.IO).launch {
migration.migrate()
}
}
})
class Migration(private val dataSource: DataSource) {
fun migrate() {
Flyway.configure()
.dataSource(dataSource)
.baselineOnMigrate(true)
.load()
.migrate()
}
}

View File

@ -0,0 +1,13 @@
package be.vandewalleh.features
import be.vandewalleh.extensions.ApplicationBuilder
import com.zaxxer.hikari.HikariDataSource
import io.ktor.application.*
class ShutdownDatabaseConnection(hikariDataSource: HikariDataSource) : ApplicationBuilder({
environment.monitor.subscribe(ApplicationStopPreparing) {
if (!hikariDataSource.isClosed) {
hikariDataSource.close()
}
}
})

View File

@ -1,20 +0,0 @@
package be.vandewalleh.migrations
import org.flywaydb.core.Flyway
import org.kodein.di.Kodein
import org.kodein.di.KodeinAware
import org.kodein.di.generic.instance
import javax.sql.DataSource
class Migration(override val kodein: Kodein) : KodeinAware {
fun migrate() {
val dataSource by instance<DataSource>()
val flyway = Flyway.configure()
.dataSource(dataSource)
.baselineOnMigrate(true)
.load()
flyway.migrate()
}
}

View File

@ -1,10 +1,10 @@
package be.vandewalleh.routing
import be.vandewalleh.auth.SimpleJWT
import be.vandewalleh.auth.UserDbIdPrincipal
import be.vandewalleh.auth.UserIdPrincipal
import be.vandewalleh.auth.UsernamePasswordCredential
import be.vandewalleh.extensions.respondStatus
import be.vandewalleh.extensions.authenticatedUserId
import be.vandewalleh.extensions.respondStatus
import be.vandewalleh.features.PasswordHash
import be.vandewalleh.services.UserService
import be.vandewalleh.validation.receiveValidated
@ -87,7 +87,7 @@ fun Route.userRoutes(kodein: Kodein) {
}
get("/me") {
val id = call.principal<UserDbIdPrincipal>()!!.id
val id = call.principal<UserIdPrincipal>()!!.id
val info = userService.find(id)
if (info != null) call.respond(mapOf("user" to info))
else call.respondStatus(HttpStatusCode.Unauthorized)

View File

@ -1,45 +1,38 @@
package be.vandewalleh.services
import be.vandewalleh.entities.Note
import be.vandewalleh.extensions.ioAsync
import be.vandewalleh.extensions.launchIo
import be.vandewalleh.tables.Notes
import be.vandewalleh.tables.Tags
import me.liuwj.ktorm.database.*
import me.liuwj.ktorm.dsl.*
import me.liuwj.ktorm.entity.*
import org.kodein.di.Kodein
import org.kodein.di.KodeinAware
import org.kodein.di.generic.instance
import java.time.LocalDateTime
import java.util.*
/**
* service to handle database queries at the Notes level.
*/
class NoteService(override val kodein: Kodein) : KodeinAware {
private val db by instance<Database>()
class NoteService(private val db: Database) {
/**
* returns a list of [Note] associated with the userId
*/
suspend fun findAll(userId: Int): List<Note> {
val notes = launchIo {
db.sequenceOf(Notes, withReferences = false)
.filterColumns { listOf(it.uuid, it.title, it.updatedAt) }
suspend fun findAll(userId: Int, limit: Int = 20, offset: Int = 0): List<Note> = launchIo {
val notes = db.sequenceOf(Notes, withReferences = false)
.filterColumns { it.columns - it.userId }
.filter { it.userId eq userId }
.sortedByDescending { it.updatedAt }
.take(limit).drop(offset)
.toList()
}
if (notes.isEmpty()) return emptyList()
if (notes.isEmpty()) return@launchIo emptyList()
val allTags = launchIo {
val allTags =
db.sequenceOf(Tags, withReferences = false)
.filterColumns { listOf(it.noteUuid, it.name) }
.filter { it.noteUuid inList notes.map { note -> note.uuid } }
.toList()
}
val tagsByUuid = allTags.groupByTo(HashMap(), { it.note.uuid }, { it.name })
@ -48,7 +41,7 @@ class NoteService(override val kodein: Kodein) : KodeinAware {
if (tags != null) it.tags = tags
}
return notes
notes
}
suspend fun exists(userId: Int, uuid: UUID) = launchIo {
@ -79,24 +72,20 @@ class NoteService(override val kodein: Kodein) : KodeinAware {
}
@Suppress("UNCHECKED_CAST")
suspend fun find(userId: Int, noteUuid: UUID): Note? {
val deferredNote = ioAsync {
suspend fun find(userId: Int, noteUuid: UUID): Note? = launchIo {
val note =
db.sequenceOf(Notes, withReferences = false)
.filterColumns { it.columns - it.userId }
.filter { it.uuid eq noteUuid }
.find { it.userId eq userId }
}
?: return@launchIo null
val deferredTags = ioAsync {
val tags =
db.sequenceOf(Tags, withReferences = false)
.filter { it.noteUuid eq noteUuid }
.mapColumns { it.name } as List<String>
}
val note = deferredNote.await() ?: return null
val tags = deferredTags.await()
return note.also { it.tags = tags }
note.also { it.tags = tags }
}
suspend fun updateNote(userId: Int, note: Note): Boolean = launchIo {

View File

@ -1,14 +0,0 @@
package be.vandewalleh.services
import org.kodein.di.Kodein
import org.kodein.di.generic.bind
import org.kodein.di.generic.singleton
/**
* [Kodein] controller module containing the app services
*/
val serviceModule = Kodein.Module(name = "Services") {
bind<NoteService>() with singleton { NoteService(this.kodein) }
bind<UserService>() with singleton { UserService(this.kodein) }
}

View File

@ -7,18 +7,12 @@ import be.vandewalleh.tables.Users
import me.liuwj.ktorm.database.*
import me.liuwj.ktorm.dsl.*
import me.liuwj.ktorm.entity.*
import org.kodein.di.Kodein
import org.kodein.di.KodeinAware
import org.kodein.di.generic.instance
import java.sql.SQLIntegrityConstraintViolationException
/**
* service to handle database queries for users.
*/
class UserService(override val kodein: Kodein) : KodeinAware {
private val db by instance<Database>()
private val passwordHash by instance<PasswordHash>()
class UserService(private val db: Database, private val passwordHash: PasswordHash) {
/**
* returns a user from it's username if found or null

View File

@ -4,7 +4,6 @@ import am.ik.yavi.builder.ValidatorBuilder
import am.ik.yavi.builder.konstraint
import am.ik.yavi.core.Validator
import be.vandewalleh.entities.Note
import be.vandewalleh.entities.User
val noteValidator: Validator<Note> = ValidatorBuilder.of<Note>()
.konstraint(Note::title) {

View File

@ -1,8 +1,8 @@
package integration.routing
import be.vandewalleh.Config
import be.vandewalleh.auth.SimpleJWT
import be.vandewalleh.entities.User
import be.vandewalleh.features.Config
import be.vandewalleh.features.PasswordHash
import be.vandewalleh.mainModule
import be.vandewalleh.module

View File

@ -4,8 +4,8 @@ import am.ik.yavi.builder.ValidatorBuilder
import am.ik.yavi.core.CustomConstraint
import am.ik.yavi.core.Validator
import be.vandewalleh.entities.Note
import be.vandewalleh.features.Migration
import be.vandewalleh.mainModule
import be.vandewalleh.migrations.Migration
import be.vandewalleh.services.NoteService
import be.vandewalleh.services.UserService
import com.fasterxml.jackson.databind.DeserializationFeature
@ -34,7 +34,7 @@ class NoteServiceTest {
private val kodein = Kodein {
import(mainModule, allowOverride = true)
bind<DataSource>(overrides = true) with singleton { mariadb.datasource() }
bind(overrides = true) from singleton { mariadb.datasource() }
}
init {
@ -216,7 +216,7 @@ class NoteServiceTest {
disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
dateFormat = StdDateFormat()
}
val note: Note = objectMapper.readValue("""{"uuid": "c6d80-5fe6-4a30-b034-da63f6663c2c"}""")
val note: Note = objectMapper.readValue("""{"uuid": "2007e4d7-2986-4188-bde1-b99916d94bad"}""")
println(note.uuid)
println(note.uuid::class.qualifiedName)
println(note.uuid.leastSignificantBits)

View File

@ -1,8 +1,9 @@
package integration.services
import be.vandewalleh.features.Migration
import be.vandewalleh.mainModule
import be.vandewalleh.migrations.Migration
import be.vandewalleh.services.UserService
import com.zaxxer.hikari.HikariDataSource
import kotlinx.coroutines.runBlocking
import org.amshove.kluent.*
import org.junit.jupiter.api.*
@ -22,17 +23,16 @@ class UserServiceTest {
private val kodein = Kodein {
import(mainModule, allowOverride = true)
bind<DataSource>(overrides = true) with singleton { mariadb.datasource() }
}
private val migration by kodein.instance<Migration>()
init {
migration.migrate()
bind(overrides = true) from singleton { mariadb.datasource() }
}
private val userService by kodein.instance<UserService>()
init {
val migration by kodein.instance<Migration>()
migration.migrate()
}
@Test
@Order(1)
fun `test create user`() {

View File

@ -6,7 +6,7 @@ import org.testcontainers.containers.MariaDBContainer
import javax.sql.DataSource
class KMariadbContainer : MariaDBContainer<KMariadbContainer>() {
fun datasource() : DataSource {
fun datasource() : HikariDataSource {
val hikariConfig = HikariConfig().apply {
jdbcUrl = this@KMariadbContainer.jdbcUrl
username = this@KMariadbContainer.username