Use transactions at the http layer

This commit is contained in:
Hubert Van De Walle 2020-10-20 22:28:24 +02:00
parent 7ed3494808
commit 9467db2382
14 changed files with 190 additions and 155 deletions

View File

@ -91,6 +91,11 @@
<artifactId>http4k-testing-hamkrest</artifactId> <artifactId>http4k-testing-hamkrest</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>me.liuwj.ktorm</groupId>
<artifactId>ktorm-core</artifactId>
</dependency>
</dependencies> </dependencies>
<dependencyManagement> <dependencyManagement>

View File

@ -19,25 +19,23 @@ class AuthFilter(
private val ctx: RequestContexts, private val ctx: RequestContexts,
private val source: JwtSource = JwtSource.Cookie, private val source: JwtSource = JwtSource.Cookie,
private val redirect: Boolean = true, private val redirect: Boolean = true,
) { ) : Filter {
operator fun invoke() = Filter { next -> override fun invoke(next: HttpHandler): HttpHandler = {
{ val token = when (source) {
val token = when (source) { JwtSource.Header -> it.bearerTokenHeader()
JwtSource.Header -> it.bearerTokenHeader() JwtSource.Cookie -> it.bearerTokenCookie()
JwtSource.Cookie -> it.bearerTokenCookie() }
val jwtPayload = token?.let { token -> extractor(token) }
when {
jwtPayload != null -> {
ctx[it][authKey] = jwtPayload
next(it)
} }
val jwtPayload = token?.let { token -> extractor(token) } authType == AuthType.Required -> {
when { if (redirect) Response.redirect("/login")
jwtPayload != null -> { else Response(UNAUTHORIZED)
ctx[it][authKey] = jwtPayload
next(it)
}
authType == AuthType.Required -> {
if (redirect) Response.redirect("/login")
else Response(UNAUTHORIZED)
}
else -> next(it)
} }
else -> next(it)
} }
} }
} }

View File

@ -2,32 +2,44 @@ package be.simplenotes.app.filters
import be.simplenotes.app.extensions.html import be.simplenotes.app.extensions.html
import be.simplenotes.app.views.ErrorView import be.simplenotes.app.views.ErrorView
import be.simplenotes.app.views.ErrorView.Type.*
import org.http4k.core.* import org.http4k.core.*
import org.http4k.core.Status.Companion.INTERNAL_SERVER_ERROR
import org.http4k.core.Status.Companion.NOT_FOUND
import org.http4k.core.Status.Companion.NOT_IMPLEMENTED
import org.http4k.core.Status.Companion.SERVICE_UNAVAILABLE
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.sql.SQLTransientException import java.sql.SQLTransientException
class ErrorFilter(private val errorView: ErrorView) { class ErrorFilter(private val errorView: ErrorView) : Filter {
private val logger = LoggerFactory.getLogger(javaClass) private val logger = LoggerFactory.getLogger(javaClass)
operator fun invoke(): Filter = Filter { next -> private fun errorResponse(status: Status): Response {
{ val type = when (status) {
try { SERVICE_UNAVAILABLE -> SqlTransientError
val response = next(it) NOT_FOUND -> NotFound
if (response.status == Status.NOT_FOUND) Response(Status.NOT_FOUND) NOT_IMPLEMENTED -> Other
.html(errorView.error(ErrorView.Type.NotFound)) else -> Other
else response }
} catch (e: Exception) {
logger.error(e.stackTraceToString()) return Response(status).html(errorView.error(type)).noCache()
if (e is SQLTransientException) }
Response(Status.SERVICE_UNAVAILABLE).html(errorView.error(ErrorView.Type.SqlTransientError))
.noCache() override fun invoke(next: HttpHandler): HttpHandler = { request ->
else try {
Response(Status.INTERNAL_SERVER_ERROR).html(errorView.error(ErrorView.Type.Other)).noCache() val response = next(request)
} catch (e: NotImplementedError) { if (response.status == NOT_FOUND) errorResponse(NOT_FOUND)
logger.error(e.stackTraceToString()) else response
Response(Status.NOT_IMPLEMENTED).html(errorView.error(ErrorView.Type.Other)).noCache() } catch (e: SQLTransientException) {
} logger.error(e.stackTraceToString())
errorResponse(SERVICE_UNAVAILABLE)
} catch (e: Exception) {
logger.error(e.stackTraceToString())
errorResponse(INTERNAL_SERVER_ERROR)
} catch (e: NotImplementedError) {
logger.error(e.stackTraceToString())
errorResponse(NOT_IMPLEMENTED)
} }
} }
} }

View File

@ -2,13 +2,10 @@ package be.simplenotes.app.filters
import org.http4k.core.Filter import org.http4k.core.Filter
import org.http4k.core.HttpHandler import org.http4k.core.HttpHandler
import org.http4k.core.Method
import org.http4k.core.Request import org.http4k.core.Request
object ImmutableFilter { object ImmutableFilter : Filter {
operator fun invoke() = Filter { next: HttpHandler -> override fun invoke(next: HttpHandler) = { request: Request ->
{ request: Request -> next(request).header("Cache-Control", "public, max-age=31536000, immutable")
next(request).header("Cache-Control", "public, max-age=31536000, immutable")
}
} }
} }

View File

@ -4,17 +4,15 @@ import org.http4k.core.Filter
import org.http4k.core.HttpHandler import org.http4k.core.HttpHandler
import org.http4k.core.Request import org.http4k.core.Request
object SecurityFilter { object SecurityFilter : Filter {
operator fun invoke() = Filter { next: HttpHandler -> override fun invoke(next: HttpHandler): HttpHandler = { request: Request ->
{ request: Request -> val response = next(request)
val response = next(request) .header("X-Content-Type-Options", "nosniff")
.header("X-Content-Type-Options", "nosniff")
if (response.header("Content-Type")?.contains("text/html") == true) { if (response.header("Content-Type")?.contains("text/html") == true) {
response response
.header("Content-Security-Policy", "default-src 'self'") .header("Content-Security-Policy", "default-src 'self'")
.header("Referrer-Policy", "no-referrer") .header("Referrer-Policy", "no-referrer")
} else response } else response
}
} }
} }

View File

@ -0,0 +1,13 @@
package be.simplenotes.app.filters
import me.liuwj.ktorm.database.Database
import org.http4k.core.Filter
import org.http4k.core.HttpHandler
class TransactionFilter(private val db: Database) : Filter {
override fun invoke(next: HttpHandler): HttpHandler = { request ->
db.useTransaction {
next(request)
}
}
}

View File

@ -5,19 +5,20 @@ import be.simplenotes.app.api.ApiUserController
import be.simplenotes.app.filters.AuthFilter import be.simplenotes.app.filters.AuthFilter
import be.simplenotes.app.filters.AuthType import be.simplenotes.app.filters.AuthType
import be.simplenotes.app.filters.JwtSource import be.simplenotes.app.filters.JwtSource
import org.http4k.core.Filter
import org.koin.core.qualifier.named import org.koin.core.qualifier.named
import org.koin.dsl.module import org.koin.dsl.module
val apiModule = module { val apiModule = module {
single { ApiUserController(get(), get()) } single { ApiUserController(get(), get()) }
single { ApiNoteController(get(), get()) } single { ApiNoteController(get(), get()) }
single(named("apiAuthFilter")) { single<Filter>(named("apiAuthFilter")) {
AuthFilter( AuthFilter(
extractor = get(), extractor = get(),
authType = AuthType.Required, authType = AuthType.Required,
ctx = get(), ctx = get(),
source = JwtSource.Header, source = JwtSource.Header,
redirect = false redirect = false
)() )
} }
} }

View File

@ -4,12 +4,14 @@ import be.simplenotes.app.Server
import be.simplenotes.app.filters.AuthFilter import be.simplenotes.app.filters.AuthFilter
import be.simplenotes.app.filters.AuthType import be.simplenotes.app.filters.AuthType
import be.simplenotes.app.filters.ErrorFilter import be.simplenotes.app.filters.ErrorFilter
import be.simplenotes.app.filters.TransactionFilter
import be.simplenotes.app.routes.Router import be.simplenotes.app.routes.Router
import be.simplenotes.app.utils.StaticFileResolver import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.app.utils.StaticFileResolverImpl import be.simplenotes.app.utils.StaticFileResolverImpl
import be.simplenotes.app.views.ErrorView import be.simplenotes.app.views.ErrorView
import be.simplenotes.shared.config.ServerConfig import be.simplenotes.shared.config.ServerConfig
import org.eclipse.jetty.server.ServerConnector import org.eclipse.jetty.server.ServerConnector
import org.http4k.core.Filter
import org.http4k.core.RequestContexts import org.http4k.core.RequestContexts
import org.http4k.routing.RoutingHttpHandler import org.http4k.routing.RoutingHttpHandler
import org.http4k.server.ConnectorBuilder import org.http4k.server.ConnectorBuilder
@ -45,14 +47,16 @@ val serverModule = module {
get(), get(),
requiredAuth = get(AuthType.Required.qualifier), requiredAuth = get(AuthType.Required.qualifier),
optionalAuth = get(AuthType.Optional.qualifier), optionalAuth = get(AuthType.Optional.qualifier),
errorFilter = get(named("ErrorFilter")),
apiAuth = get(named("apiAuthFilter")), apiAuth = get(named("apiAuthFilter")),
get() get(),
get(),
get(),
)() )()
} }
single { RequestContexts() } single { RequestContexts() }
single(AuthType.Optional.qualifier) { AuthFilter(get(), AuthType.Optional, get())() } single<Filter>(AuthType.Optional.qualifier) { AuthFilter(get(), AuthType.Optional, get()) }
single(AuthType.Required.qualifier) { AuthFilter(get(), AuthType.Required, get())() } single<Filter>(AuthType.Required.qualifier) { AuthFilter(get(), AuthType.Required, get()) }
single(named("ErrorFilter")) { ErrorFilter(get())() } single { ErrorFilter(get()) }
single { TransactionFilter(get()) }
single { ErrorView(get()) } single { ErrorView(get()) }
} }

View File

@ -6,15 +6,14 @@ import be.simplenotes.app.controllers.BaseController
import be.simplenotes.app.controllers.NoteController import be.simplenotes.app.controllers.NoteController
import be.simplenotes.app.controllers.SettingsController import be.simplenotes.app.controllers.SettingsController
import be.simplenotes.app.controllers.UserController import be.simplenotes.app.controllers.UserController
import be.simplenotes.app.filters.ImmutableFilter import be.simplenotes.app.filters.*
import be.simplenotes.app.filters.SecurityFilter
import be.simplenotes.app.filters.jwtPayload
import be.simplenotes.domain.security.JwtPayload import be.simplenotes.domain.security.JwtPayload
import org.http4k.core.* import org.http4k.core.*
import org.http4k.core.Method.* import org.http4k.core.Method.*
import org.http4k.filter.ResponseFilters import org.http4k.filter.ResponseFilters.GZip
import org.http4k.filter.ServerFilters.InitialiseRequestContext import org.http4k.filter.ServerFilters.InitialiseRequestContext
import org.http4k.routing.* import org.http4k.routing.*
import org.http4k.routing.ResourceLoader.Companion.Classpath
class Router( class Router(
private val baseController: BaseController, private val baseController: BaseController,
@ -25,24 +24,19 @@ class Router(
private val apiNoteController: ApiNoteController, private val apiNoteController: ApiNoteController,
private val requiredAuth: Filter, private val requiredAuth: Filter,
private val optionalAuth: Filter, private val optionalAuth: Filter,
private val errorFilter: Filter,
private val apiAuth: Filter, private val apiAuth: Filter,
private val errorFilter: ErrorFilter,
private val transactionFilter: TransactionFilter,
private val contexts: RequestContexts, private val contexts: RequestContexts,
) { ) {
operator fun invoke(): RoutingHttpHandler { operator fun invoke(): RoutingHttpHandler {
val resourceLoader = ResourceLoader.Classpath(("/static")) val basicRoutes = ImmutableFilter.then(static(Classpath("/static"), "woff2" to ContentType("font/woff2")))
val basicRoutes = routes(
ImmutableFilter().then(static(resourceLoader, "woff2" to ContentType("font/woff2"))),
)
infix fun PathMethod.public(handler: PublicHandler) = this to { handler(it, it.jwtPayload(contexts)) } val publicRoutes = routes(
infix fun PathMethod.protected(handler: ProtectedHandler) = this to { handler(it, it.jwtPayload(contexts)!!) }
val publicRoutes: RoutingHttpHandler = routes(
"/" bind GET public baseController::index, "/" bind GET public baseController::index,
"/register" bind GET public userController::register, "/register" bind GET public userController::register,
"/register" bind POST public userController::register, "/register" bind POST `public transactional` userController::register,
"/login" bind GET public userController::login, "/login" bind GET public userController::login,
"/login" bind POST public userController::login, "/login" bind POST public userController::login,
"/logout" bind POST to userController::logout, "/logout" bind POST to userController::logout,
@ -51,18 +45,18 @@ class Router(
val protectedRoutes = routes( val protectedRoutes = routes(
"/settings" bind GET protected settingsController::settings, "/settings" bind GET protected settingsController::settings,
"/settings" bind POST protected settingsController::settings, "/settings" bind POST transactional settingsController::settings,
"/export" bind POST protected settingsController::export, "/export" bind POST protected settingsController::export,
"/notes" bind GET protected noteController::list, "/notes" bind GET protected noteController::list,
"/notes" bind POST protected noteController::search, "/notes" bind POST protected noteController::search,
"/notes/new" bind GET protected noteController::new, "/notes/new" bind GET protected noteController::new,
"/notes/new" bind POST protected noteController::new, "/notes/new" bind POST transactional noteController::new,
"/notes/trash" bind GET protected noteController::trash, "/notes/trash" bind GET protected noteController::trash,
"/notes/{uuid}" bind GET protected noteController::note, "/notes/{uuid}" bind GET protected noteController::note,
"/notes/{uuid}" bind POST protected noteController::note, "/notes/{uuid}" bind POST transactional noteController::note,
"/notes/{uuid}/edit" bind GET protected noteController::edit, "/notes/{uuid}/edit" bind GET protected noteController::edit,
"/notes/{uuid}/edit" bind POST protected noteController::edit, "/notes/{uuid}/edit" bind POST transactional noteController::edit,
"/notes/deleted/{uuid}" bind POST protected noteController::deleted, "/notes/deleted/{uuid}" bind POST transactional noteController::deleted,
) )
val apiRoutes = routes( val apiRoutes = routes(
@ -71,10 +65,10 @@ class Router(
val protectedApiRoutes = routes( val protectedApiRoutes = routes(
"/api/notes" bind GET protected apiNoteController::notes, "/api/notes" bind GET protected apiNoteController::notes,
"/api/notes" bind POST protected apiNoteController::createNote, "/api/notes" bind POST transactional apiNoteController::createNote,
"/api/notes/search" bind POST protected apiNoteController::search, "/api/notes/search" bind POST transactional apiNoteController::search,
"/api/notes/{uuid}" bind GET protected apiNoteController::note, "/api/notes/{uuid}" bind GET protected apiNoteController::note,
"/api/notes/{uuid}" bind PUT protected apiNoteController::update, "/api/notes/{uuid}" bind PUT transactional apiNoteController::update,
) )
val routes = routes( val routes = routes(
@ -87,11 +81,23 @@ class Router(
val globalFilters = errorFilter val globalFilters = errorFilter
.then(InitialiseRequestContext(contexts)) .then(InitialiseRequestContext(contexts))
.then(SecurityFilter()) .then(SecurityFilter)
.then(ResponseFilters.GZip()) .then(GZip())
return globalFilters.then(routes) return globalFilters.then(routes)
} }
private inline infix fun PathMethod.public(crossinline handler: PublicHandler) =
this to { handler(it, it.jwtPayload(contexts)) }
private inline infix fun PathMethod.protected(crossinline handler: ProtectedHandler) =
this to { handler(it, it.jwtPayload(contexts)!!) }
private inline infix fun PathMethod.transactional(crossinline handler: ProtectedHandler) =
this to transactionFilter.then { handler(it, it.jwtPayload(contexts)!!) }
private inline infix fun PathMethod.`public transactional`(crossinline handler: PublicHandler) =
this to transactionFilter.then { handler(it, it.jwtPayload(contexts)) }
} }
private typealias PublicHandler = (Request, JwtPayload?) -> Response private typealias PublicHandler = (Request, JwtPayload?) -> Response

View File

@ -27,8 +27,8 @@ internal class AuthFilterTest {
private val simpleJwt = SimpleJwt(jwtConfig) private val simpleJwt = SimpleJwt(jwtConfig)
private val extractor = JwtPayloadExtractor(simpleJwt) private val extractor = JwtPayloadExtractor(simpleJwt)
private val ctx = RequestContexts() private val ctx = RequestContexts()
private val requiredAuth = AuthFilter(extractor, AuthType.Required, ctx)() private val requiredAuth = AuthFilter(extractor, AuthType.Required, ctx)
private val optionalAuth = AuthFilter(extractor, AuthType.Optional, ctx)() private val optionalAuth = AuthFilter(extractor, AuthType.Optional, ctx)
private val echoJwtPayloadHandler = { request: Request -> Response(OK).body(request.jwtPayload(ctx).toString()) } private val echoJwtPayloadHandler = { request: Request -> Response(OK).body(request.jwtPayload(ctx).toString()) }

View File

@ -63,12 +63,10 @@
<dependency> <dependency>
<groupId>me.liuwj.ktorm</groupId> <groupId>me.liuwj.ktorm</groupId>
<artifactId>ktorm-core</artifactId> <artifactId>ktorm-core</artifactId>
<version>3.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>me.liuwj.ktorm</groupId> <groupId>me.liuwj.ktorm</groupId>
<artifactId>ktorm-support-mysql</artifactId> <artifactId>ktorm-support-mysql</artifactId>
<version>3.0.0</version>
</dependency> </dependency>
</dependencies> </dependencies>

View File

@ -1,6 +1,9 @@
package be.simplenotes.persistance.notes package be.simplenotes.persistance.notes
import be.simplenotes.domain.model.* import be.simplenotes.domain.model.ExportedNote
import be.simplenotes.domain.model.Note
import be.simplenotes.domain.model.PersistedNote
import be.simplenotes.domain.model.PersistedNoteMetadata
import be.simplenotes.domain.usecases.repositories.NoteRepository import be.simplenotes.domain.usecases.repositories.NoteRepository
import me.liuwj.ktorm.database.Database import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.dsl.* import me.liuwj.ktorm.dsl.*
@ -59,14 +62,12 @@ internal class NoteRepositoryImpl(private val db: Database) : NoteRepository {
val entity = note.toEntity(uuid, userId).apply { val entity = note.toEntity(uuid, userId).apply {
this.updatedAt = LocalDateTime.now() this.updatedAt = LocalDateTime.now()
} }
db.useTransaction { db.notes.add(entity)
db.notes.add(entity) db.batchInsert(Tags) {
db.batchInsert(Tags) { note.meta.tags.forEach { tagName ->
note.meta.tags.forEach { tagName -> item {
item { it.noteUuid to uuid
it.noteUuid to uuid it.name to tagName
it.name to tagName
}
} }
} }
} }
@ -90,64 +91,56 @@ internal class NoteRepositoryImpl(private val db: Database) : NoteRepository {
} }
override fun update(userId: Int, uuid: UUID, note: Note): PersistedNote? { override fun update(userId: Int, uuid: UUID, note: Note): PersistedNote? {
db.useTransaction { val now = LocalDateTime.now()
val count = db.update(Notes) {
val now = LocalDateTime.now() it.title to note.meta.title
val count = db.update(Notes) { it.markdown to note.markdown
it.title to note.meta.title it.html to note.html
it.markdown to note.markdown it.updatedAt to now
it.html to note.html where { (it.uuid eq uuid) and (it.userId eq userId) and (it.deleted eq false) }
it.updatedAt to now
where { (it.uuid eq uuid) and (it.userId eq userId) and (it.deleted eq false) }
}
if (count == 0) return null
// delete all tags
db.delete(Tags) {
it.noteUuid eq uuid
}
// put new ones
note.meta.tags.forEach { tagName ->
db.insert(Tags) {
it.name to tagName
it.noteUuid to uuid
}
}
return PersistedNote(
meta = note.meta,
markdown = note.markdown,
html = note.html,
updatedAt = now,
uuid = uuid,
public = false, // TODO
)
} }
if (count == 0) return null
// delete all tags
db.delete(Tags) {
it.noteUuid eq uuid
}
// put new ones
note.meta.tags.forEach { tagName ->
db.insert(Tags) {
it.name to tagName
it.noteUuid to uuid
}
}
return PersistedNote(
meta = note.meta,
markdown = note.markdown,
html = note.html,
updatedAt = now,
uuid = uuid,
public = false, // TODO
)
} }
override fun delete(userId: Int, uuid: UUID, permanent: Boolean): Boolean { override fun delete(userId: Int, uuid: UUID, permanent: Boolean): Boolean {
return if (!permanent) { return if (!permanent) {
db.useTransaction { db.update(Notes) {
db.update(Notes) { it.deleted to true
it.deleted to true it.updatedAt to LocalDateTime.now()
it.updatedAt to LocalDateTime.now() where { it.userId eq userId and (it.uuid eq uuid) }
where { it.userId eq userId and (it.uuid eq uuid) }
}
} == 1 } == 1
} else db.useTransaction { } else
db.delete(Notes) { it.uuid eq uuid and (it.userId eq userId) } == 1 db.delete(Notes) { it.uuid eq uuid and (it.userId eq userId) } == 1
}
} }
override fun restore(userId: Int, uuid: UUID): Boolean { override fun restore(userId: Int, uuid: UUID): Boolean {
return db.useTransaction { return db.update(Notes) {
db.update(Notes) { it.deleted to false
it.deleted to false where { (it.userId eq userId) and (it.uuid eq uuid) }
where { (it.userId eq userId) and (it.uuid eq uuid) } } == 1
} == 1
}
} }
override fun getTags(userId: Int): List<String> = override fun getTags(userId: Int): List<String> =

View File

@ -3,21 +3,20 @@ package be.simplenotes.persistance.users
import be.simplenotes.domain.model.PersistedUser import be.simplenotes.domain.model.PersistedUser
import be.simplenotes.domain.model.User import be.simplenotes.domain.model.User
import be.simplenotes.domain.usecases.repositories.UserRepository import be.simplenotes.domain.usecases.repositories.UserRepository
import me.liuwj.ktorm.database.* import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.dsl.* import me.liuwj.ktorm.dsl.*
import me.liuwj.ktorm.entity.* import me.liuwj.ktorm.entity.any
import me.liuwj.ktorm.entity.find
import java.sql.SQLIntegrityConstraintViolationException import java.sql.SQLIntegrityConstraintViolationException
internal class UserRepositoryImpl(private val db: Database) : UserRepository { internal class UserRepositoryImpl(private val db: Database) : UserRepository {
override fun create(user: User): PersistedUser? { override fun create(user: User): PersistedUser? {
return try { return try {
db.useTransaction { val id = db.insertAndGenerateKey(Users) {
val id = db.insertAndGenerateKey(Users) { it.username to user.username
it.username to user.username it.password to user.password
it.password to user.password } as Int
} as Int PersistedUser(user.username, user.password, id)
PersistedUser(user.username, user.password, id)
}
} catch (e: SQLIntegrityConstraintViolationException) { } catch (e: SQLIntegrityConstraintViolationException) {
null null
} }
@ -27,6 +26,6 @@ internal class UserRepositoryImpl(private val db: Database) : UserRepository {
override fun find(id: Int) = db.users.find { it.id eq id }?.toPersistedUser() override fun find(id: Int) = db.users.find { it.id eq id }?.toPersistedUser()
override fun exists(username: String) = db.users.any { it.username eq username } override fun exists(username: String) = db.users.any { it.username eq username }
override fun exists(id: Int) = db.users.any { it.id eq id } override fun exists(id: Int) = db.users.any { it.id eq id }
override fun delete(id: Int) = db.useTransaction { db.delete(Users) { it.id eq id } == 1 } override fun delete(id: Int) = db.delete(Users) { it.id eq id } == 1
override fun findAll() = db.from(Users).select(Users.id).map { it[Users.id]!! } override fun findAll() = db.from(Users).select(Users.id).map { it[Users.id]!! }
} }

11
pom.xml
View File

@ -170,6 +170,17 @@
<version>1.7.25</version> <version>1.7.25</version>
</dependency> </dependency>
<dependency>
<groupId>me.liuwj.ktorm</groupId>
<artifactId>ktorm-core</artifactId>
<version>3.0.0</version>
</dependency>
<dependency>
<groupId>me.liuwj.ktorm</groupId>
<artifactId>ktorm-support-mysql</artifactId>
<version>3.0.0</version>
</dependency>
<!-- region tests --> <!-- region tests -->
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>