15 Commits

Author SHA1 Message Date
hubert 32337ec308 Use mapstruct 2020-10-21 22:02:34 +02:00
hubert 681fd635b3 Add health check 2020-10-21 16:30:42 +02:00
hubert 9467db2382 Use transactions at the http layer 2020-10-20 23:26:20 +02:00
hubert 7ed3494808 Clean pom common dependencies 2020-10-20 19:21:29 +02:00
hubert ceb310bf02 Fix favicon extension 2020-10-20 16:10:13 +02:00
hubert 2c3106c5c1 Clean homepage html 2020-10-20 15:56:24 +02:00
hubert 4effa8231a Change icons 2020-10-20 15:55:11 +02:00
hubert b78420e106 Update some dependencies 2020-10-17 03:39:44 +02:00
hubert dd08763161 We don't need that much RAM 2020-09-30 18:57:46 +02:00
hubert e0b1514965 Unload bootstraping modules after startup 2020-09-30 18:56:52 +02:00
hubert 69c91ec86a Proper shutdown 2020-09-30 00:18:09 +02:00
hubert 1bc45461c3 Small refactor 2020-09-30 00:10:37 +02:00
hubert 0dfb2a7e03 Add zip export 2020-09-28 19:21:53 +02:00
hubert a7c8e63b11 Add radio input in order to select the export format 2020-09-28 18:29:07 +02:00
hubert ad97ba029e Fix small bug 2020-09-02 22:13:08 +02:00
66 changed files with 1358 additions and 672 deletions
+3 -1
View File
@@ -32,6 +32,8 @@ RUN strip -p --strip-unneeded /myjdk/lib/server/libjvm.so
FROM alpine
RUN apk add --no-cache curl
ENV APPLICATION_USER simplenotes
RUN adduser -D -g '' $APPLICATION_USER
@@ -44,4 +46,4 @@ COPY --from=builder /tmp/app/target/app-*.jar /app/app.jar
COPY --from=jdkbuilder /myjdk /myjdk
WORKDIR /app
CMD ["/myjdk/bin/java", "-server", "-XX:+UnlockExperimentalVMOptions", "-Xms256m", "-Xmx1g", "-XX:+UseG1GC", "-XX:MaxGCPauseMillis=100", "-XX:+UseStringDeduplication", "-jar", "app.jar"]
CMD ["/myjdk/bin/java", "-server", "-XX:+UnlockExperimentalVMOptions", "-Xms64m", "-Xmx256m", "-XX:+UseG1GC", "-XX:MaxGCPauseMillis=100", "-XX:+UseStringDeduplication", "-jar", "app.jar"]
+43 -7
View File
@@ -1,4 +1,6 @@
<project>
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>parent</artifactId>
<groupId>be.simplenotes</groupId>
@@ -9,7 +11,7 @@
<artifactId>app</artifactId>
<properties>
<http4k.version>3.258.0</http4k.version>
<http4k.version>3.268.0</http4k.version>
</properties>
<dependencies>
@@ -36,12 +38,16 @@
<dependency>
<groupId>org.http4k</groupId>
<artifactId>http4k-core</artifactId>
<version>${http4k.version}</version>
</dependency>
<dependency>
<groupId>org.http4k</groupId>
<artifactId>http4k-server-jetty</artifactId>
<version>${http4k.version}</version>
<exclusions>
<exclusion>
<groupId>org.eclipse.jetty.websocket</groupId>
<artifactId>javax-websocket-server-impl</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
@@ -50,7 +56,7 @@
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-serialization-runtime</artifactId>
<artifactId>kotlinx-serialization-json-jvm</artifactId>
</dependency>
<dependency>
<groupId>org.ocpsoft.prettytime</groupId>
@@ -58,6 +64,21 @@
<version>4.0.5.Final</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>be.simplenotes</groupId>
<artifactId>shared</artifactId>
@@ -68,17 +89,32 @@
<dependency>
<groupId>org.http4k</groupId>
<artifactId>http4k-testing-hamkrest</artifactId>
<version>${http4k.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>me.liuwj.ktorm</groupId>
<artifactId>ktorm-core</artifactId>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.http4k</groupId>
<artifactId>http4k-bom</artifactId>
<version>${http4k.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.4</version>
<executions>
<execution>
<phase>package</phase>
+1 -1
View File
@@ -6,7 +6,7 @@ import be.simplenotes.shared.config.ServerConfig
import java.util.*
import java.util.concurrent.TimeUnit
object Config {
class Config {
//region Config loading
private val properties: Properties = javaClass
.getResource("/application.properties")
+13 -22
View File
@@ -1,32 +1,23 @@
package be.simplenotes.app
import org.eclipse.jetty.server.Server
import org.eclipse.jetty.server.ServerConnector
import org.http4k.routing.RoutingHttpHandler
import org.http4k.server.ConnectorBuilder
import org.http4k.server.Jetty
import org.http4k.server.ServerConfig
import org.http4k.server.asServer
import org.http4k.server.Http4kServer
import org.slf4j.LoggerFactory
import be.simplenotes.shared.config.ServerConfig as SimpleNotesServeConfig
import be.simplenotes.shared.config.ServerConfig as SimpleNotesServerConfig
class Server(
private val config: SimpleNotesServeConfig,
private val serverConfig: ServerConfig,
private val router: RoutingHttpHandler,
private val config: SimpleNotesServerConfig,
private val http4kServer: Http4kServer,
) {
fun start() {
router.asServer(serverConfig).start()
LoggerFactory.getLogger(javaClass).info("Listening on http://${config.host}:${config.port}")
}
}
private val logger = LoggerFactory.getLogger(javaClass)
fun serverConfig(config: SimpleNotesServeConfig): ServerConfig {
val builder: ConnectorBuilder = { server: Server ->
ServerConnector(server).apply {
port = config.port
host = config.host
fun start(): Server {
http4kServer.start()
logger.info("Listening on http://${config.host}:${config.port}")
return this
}
fun stop() {
logger.info("Stopping server")
http4kServer.close()
}
return Jetty(config.port, builder)
}
+13 -162
View File
@@ -1,180 +1,31 @@
package be.simplenotes.app
import be.simplenotes.app.api.ApiNoteController
import be.simplenotes.app.api.ApiUserController
import be.simplenotes.app.controllers.BaseController
import be.simplenotes.app.controllers.NoteController
import be.simplenotes.app.controllers.SettingsController
import be.simplenotes.app.controllers.UserController
import be.simplenotes.app.filters.AuthFilter
import be.simplenotes.app.filters.AuthType
import be.simplenotes.app.filters.ErrorFilter
import be.simplenotes.app.filters.JwtSource
import be.simplenotes.app.routes.Router
import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.app.utils.StaticFileResolverImpl
import be.simplenotes.app.views.*
import be.simplenotes.app.extensions.addShutdownHook
import be.simplenotes.app.modules.*
import be.simplenotes.domain.domainModule
import be.simplenotes.domain.usecases.NoteService
import be.simplenotes.persistance.DbMigrations
import be.simplenotes.persistance.migrationModule
import be.simplenotes.persistance.persistanceModule
import be.simplenotes.search.searchModule
import be.simplenotes.shared.config.DataSourceConfig
import be.simplenotes.shared.config.JwtConfig
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import org.http4k.core.RequestContexts
import org.koin.core.context.startKoin
import org.koin.core.qualifier.named
import org.koin.core.qualifier.qualifier
import org.koin.dsl.module
import org.slf4j.LoggerFactory
import java.time.LocalDateTime
import java.util.*
import be.simplenotes.shared.config.ServerConfig as SimpleNotesServeConfig
import org.koin.core.context.unloadKoinModules
fun main() {
val koin = startKoin {
startKoin {
modules(
persistanceModule,
configModule,
domainModule,
serverModule,
userModule,
persistanceModule,
migrationModule,
configModule,
baseModule,
userModule,
noteModule,
settingsModule,
domainModule,
searchModule,
apiModule,
jsonModule
)
}.koin
}.addShutdownHook()
val dataSourceConfig = koin.get<DataSourceConfig>()
val jwtConfig = koin.get<JwtConfig>()
val serverConfig = koin.get<SimpleNotesServeConfig>()
val logger = LoggerFactory.getLogger("SimpleNotes")
logger.info("datasource: $dataSourceConfig")
logger.info("jwt: $jwtConfig")
logger.info("server: $serverConfig")
val migrations = koin.get<DbMigrations>()
migrations.migrate()
val noteService = koin.get<NoteService>()
noteService.dropAllIndexes()
noteService.indexAll()
koin.get<Server>().start()
unloadKoinModules(listOf(migrationModule, configModule))
}
val serverModule = module {
single { Server(get(), get(), get()) }
single<StaticFileResolver> { StaticFileResolverImpl() }
single {
Router(
get(),
get(),
get(),
get(),
get(),
get(),
requiredAuth = get(AuthType.Required.qualifier),
optionalAuth = get(AuthType.Optional.qualifier),
errorFilter = get(named("ErrorFilter")),
apiAuth = get(named("apiAuthFilter")),
get()
)()
}
single { serverConfig(get()) }
single { RequestContexts() }
single(AuthType.Optional.qualifier) { AuthFilter(get(), AuthType.Optional, get())() }
single(AuthType.Required.qualifier) { AuthFilter(get(), AuthType.Required, get())() }
single(named("ErrorFilter")) { ErrorFilter(get())() }
single { ErrorView(get()) }
}
val userModule = module {
single { UserController(get(), get(), get()) }
single { UserView(get()) }
}
val baseModule = module {
single { BaseController(get()) }
single { BaseView(get()) }
}
val noteModule = module {
single { NoteController(get(), get()) }
single { NoteView(get()) }
}
val settingsModule = module {
single { SettingsController(get(), get()) }
single { SettingView(get()) }
}
val configModule = module {
single { Config.dataSourceConfig }
single { Config.jwtConfig }
single { Config.serverConfig }
}
val apiModule = module {
single { ApiUserController(get(), get()) }
single { ApiNoteController(get(), get()) }
single {
Json {
prettyPrint = true
serializersModule = get()
}
}
single {
SerializersModule {
contextual(LocalDateTime::class, LocalDateTimeSerializer)
contextual(UUID::class, UuidSerializer)
}
}
single(named("apiAuthFilter")) {
AuthFilter(
extractor = get(),
authType = AuthType.Required,
ctx = get(),
source = JwtSource.Header,
redirect = false
)()
}
}
internal object LocalDateTimeSerializer : KSerializer<LocalDateTime> {
override val descriptor: SerialDescriptor
get() = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: LocalDateTime) {
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): LocalDateTime {
TODO("Not implemented, isn't needed")
}
}
internal object UuidSerializer : KSerializer<UUID> {
override val descriptor: SerialDescriptor
get() = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: UUID) {
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): UUID {
TODO()
}
}
@@ -0,0 +1,12 @@
package be.simplenotes.app.controllers
import be.simplenotes.persistance.DbHealthCheck
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.OK
import org.http4k.core.Status.Companion.SERVICE_UNAVAILABLE
class HealthCheckController(private val dbHealthCheck: DbHealthCheck) {
fun healthCheck(request: Request) =
if (dbHealthCheck.isOk()) Response(OK) else Response(SERVICE_UNAVAILABLE)
}
@@ -7,10 +7,7 @@ import be.simplenotes.domain.security.JwtPayload
import be.simplenotes.domain.usecases.UserService
import be.simplenotes.domain.usecases.users.delete.DeleteError
import be.simplenotes.domain.usecases.users.delete.DeleteForm
import org.http4k.core.Method
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status
import org.http4k.core.*
import org.http4k.core.body.form
import org.http4k.core.cookie.invalidateCookie
@@ -44,23 +41,34 @@ class SettingsController(
}
},
{
Response.redirect("/").invalidateCookie("Authorization")
Response.redirect("/").invalidateCookie("Bearer")
}
)
}
private fun attachment(filename: String, contentType: String) = { response: Response ->
val name = filename.replace("[^a-zA-Z0-9-_.]".toRegex(), "_")
response
.header("Content-Disposition", "attachment; filename=\"$name\"")
.header("Content-Type", contentType)
}
fun export(request: Request, jwtPayload: JwtPayload): Response {
val isDownload = request.form("download") != null
val json = userService.export(jwtPayload.userId)
val res = Response(Status.OK).body(json).header("Content-Type", "application/json")
return if (isDownload) res.header(
"Content-Disposition",
"attachment; filename=\"simplenotes-export-${sanitizeFilename(jwtPayload.username)}.json\""
)
else res
}
private fun sanitizeFilename(inputName: String): String = inputName.replace("[^a-zA-Z0-9-_.]".toRegex(), "_")
return if (isDownload) {
val filename = "simplenotes-export-${jwtPayload.username}"
if (request.form("format") == "zip") {
val zip = userService.exportAsZip(jwtPayload.userId)
Response(Status.OK)
.with(attachment("$filename.zip", "application/zip"))
.body(zip)
} else
Response(Status.OK)
.with(attachment("$filename.json", "application/json"))
.body(userService.exportAsJson(jwtPayload.userId))
} else Response(Status.OK).body(userService.exportAsJson(jwtPayload.userId)).header("Content-Type", "application/json")
}
private fun Request.deleteForm(jwtPayload: JwtPayload) =
DeleteForm(jwtPayload.username, form("password"), form("checked") != null)
@@ -0,0 +1,12 @@
package be.simplenotes.app.extensions
import org.koin.core.KoinApplication
import kotlin.concurrent.thread
fun KoinApplication.addShutdownHook() {
Runtime.getRuntime().addShutdownHook(
thread(start = false) {
close()
}
)
}
+2 -4
View File
@@ -19,9 +19,8 @@ class AuthFilter(
private val ctx: RequestContexts,
private val source: JwtSource = JwtSource.Cookie,
private val redirect: Boolean = true,
) {
operator fun invoke() = Filter { next ->
{
) : Filter {
override fun invoke(next: HttpHandler): HttpHandler = {
val token = when (source) {
JwtSource.Header -> it.bearerTokenHeader()
JwtSource.Cookie -> it.bearerTokenCookie()
@@ -39,7 +38,6 @@ class AuthFilter(
else -> next(it)
}
}
}
}
fun Request.jwtPayload(ctx: RequestContexts): JwtPayload? = ctx[this][authKey]
+25 -13
View File
@@ -2,32 +2,44 @@ package be.simplenotes.app.filters
import be.simplenotes.app.extensions.html
import be.simplenotes.app.views.ErrorView
import be.simplenotes.app.views.ErrorView.Type.*
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 java.sql.SQLTransientException
class ErrorFilter(private val errorView: ErrorView) {
class ErrorFilter(private val errorView: ErrorView) : Filter {
private val logger = LoggerFactory.getLogger(javaClass)
operator fun invoke(): Filter = Filter { next ->
{
private fun errorResponse(status: Status): Response {
val type = when (status) {
SERVICE_UNAVAILABLE -> SqlTransientError
NOT_FOUND -> NotFound
NOT_IMPLEMENTED -> Other
else -> Other
}
return Response(status).html(errorView.error(type)).noCache()
}
override fun invoke(next: HttpHandler): HttpHandler = { request ->
try {
val response = next(it)
if (response.status == Status.NOT_FOUND) Response(Status.NOT_FOUND)
.html(errorView.error(ErrorView.Type.NotFound))
val response = next(request)
if (response.status == NOT_FOUND) errorResponse(NOT_FOUND)
else response
} catch (e: SQLTransientException) {
logger.error(e.stackTraceToString())
errorResponse(SERVICE_UNAVAILABLE)
} catch (e: Exception) {
logger.error(e.stackTraceToString())
if (e is SQLTransientException)
Response(Status.SERVICE_UNAVAILABLE).html(errorView.error(ErrorView.Type.SqlTransientError))
.noCache()
else
Response(Status.INTERNAL_SERVER_ERROR).html(errorView.error(ErrorView.Type.Other)).noCache()
errorResponse(INTERNAL_SERVER_ERROR)
} catch (e: NotImplementedError) {
logger.error(e.stackTraceToString())
Response(Status.NOT_IMPLEMENTED).html(errorView.error(ErrorView.Type.Other)).noCache()
}
errorResponse(NOT_IMPLEMENTED)
}
}
}
@@ -2,13 +2,10 @@ package be.simplenotes.app.filters
import org.http4k.core.Filter
import org.http4k.core.HttpHandler
import org.http4k.core.Method
import org.http4k.core.Request
object ImmutableFilter {
operator fun invoke() = Filter { next: HttpHandler ->
{ request: Request ->
object ImmutableFilter : Filter {
override fun invoke(next: HttpHandler) = { request: Request ->
next(request).header("Cache-Control", "public, max-age=31536000, immutable")
}
}
}
@@ -4,9 +4,8 @@ import org.http4k.core.Filter
import org.http4k.core.HttpHandler
import org.http4k.core.Request
object SecurityFilter {
operator fun invoke() = Filter { next: HttpHandler ->
{ request: Request ->
object SecurityFilter : Filter {
override fun invoke(next: HttpHandler): HttpHandler = { request: Request ->
val response = next(request)
.header("X-Content-Type-Options", "nosniff")
@@ -16,5 +15,4 @@ object SecurityFilter {
.header("Referrer-Policy", "no-referrer")
} else response
}
}
}
@@ -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)
}
}
}
+24
View File
@@ -0,0 +1,24 @@
package be.simplenotes.app.modules
import be.simplenotes.app.api.ApiNoteController
import be.simplenotes.app.api.ApiUserController
import be.simplenotes.app.filters.AuthFilter
import be.simplenotes.app.filters.AuthType
import be.simplenotes.app.filters.JwtSource
import org.http4k.core.Filter
import org.koin.core.qualifier.named
import org.koin.dsl.module
val apiModule = module {
single { ApiUserController(get(), get()) }
single { ApiNoteController(get(), get()) }
single<Filter>(named("apiAuthFilter")) {
AuthFilter(
extractor = get(),
authType = AuthType.Required,
ctx = get(),
source = JwtSource.Header,
redirect = false
)
}
}
@@ -0,0 +1,11 @@
package be.simplenotes.app.modules
import be.simplenotes.app.Config
import org.koin.dsl.module
val configModule = module {
single { Config() }
single { get<Config>().dataSourceConfig }
single { get<Config>().jwtConfig }
single { get<Config>().serverConfig }
}
@@ -0,0 +1,29 @@
package be.simplenotes.app.modules
import be.simplenotes.app.controllers.*
import be.simplenotes.app.views.BaseView
import be.simplenotes.app.views.NoteView
import be.simplenotes.app.views.SettingView
import be.simplenotes.app.views.UserView
import org.koin.dsl.module
val userModule = module {
single { UserController(get(), get(), get()) }
single { UserView(get()) }
}
val baseModule = module {
single { HealthCheckController(get()) }
single { BaseController(get()) }
single { BaseView(get()) }
}
val noteModule = module {
single { NoteController(get(), get()) }
single { NoteView(get()) }
}
val settingsModule = module {
single { SettingsController(get(), get()) }
single { SettingView(get()) }
}
+24
View File
@@ -0,0 +1,24 @@
package be.simplenotes.app.modules
import be.simplenotes.app.serialization.LocalDateTimeSerializer
import be.simplenotes.app.serialization.UuidSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import org.koin.dsl.module
import java.time.LocalDateTime
import java.util.*
val jsonModule = module {
single {
Json {
prettyPrint = true
serializersModule = get()
}
}
single {
SerializersModule {
contextual(LocalDateTime::class, LocalDateTimeSerializer())
contextual(UUID::class, UuidSerializer())
}
}
}
@@ -0,0 +1,63 @@
package be.simplenotes.app.modules
import be.simplenotes.app.Server
import be.simplenotes.app.filters.AuthFilter
import be.simplenotes.app.filters.AuthType
import be.simplenotes.app.filters.ErrorFilter
import be.simplenotes.app.filters.TransactionFilter
import be.simplenotes.app.routes.Router
import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.app.utils.StaticFileResolverImpl
import be.simplenotes.app.views.ErrorView
import be.simplenotes.shared.config.ServerConfig
import org.eclipse.jetty.server.ServerConnector
import org.http4k.core.Filter
import org.http4k.core.RequestContexts
import org.http4k.routing.RoutingHttpHandler
import org.http4k.server.ConnectorBuilder
import org.http4k.server.Jetty
import org.http4k.server.asServer
import org.koin.core.qualifier.named
import org.koin.core.qualifier.qualifier
import org.koin.dsl.module
import org.koin.dsl.onClose
import org.http4k.server.ServerConfig as Http4kServerConfig
val serverModule = module {
single(createdAtStart = true) { Server(get(), get()).start() } onClose { it?.stop() }
single { get<RoutingHttpHandler>().asServer(get()) }
single<Http4kServerConfig> {
val config = get<ServerConfig>()
val builder: ConnectorBuilder = { server: org.eclipse.jetty.server.Server ->
ServerConnector(server).apply {
port = config.port
host = config.host
}
}
Jetty(config.port, builder)
}
single<StaticFileResolver> { StaticFileResolverImpl(get()) }
single {
Router(
get(),
get(),
get(),
get(),
get(),
get(),
get(),
requiredAuth = get(AuthType.Required.qualifier),
optionalAuth = get(AuthType.Optional.qualifier),
apiAuth = get(named("apiAuthFilter")),
get(),
get(),
get(),
)()
}
single { RequestContexts() }
single<Filter>(AuthType.Optional.qualifier) { AuthFilter(get(), AuthType.Optional, get()) }
single<Filter>(AuthType.Required.qualifier) { AuthFilter(get(), AuthType.Required, get()) }
single { ErrorFilter(get()) }
single { TransactionFilter(get()) }
single { ErrorView(get()) }
}
+35 -27
View File
@@ -2,19 +2,15 @@ package be.simplenotes.app.routes
import be.simplenotes.app.api.ApiNoteController
import be.simplenotes.app.api.ApiUserController
import be.simplenotes.app.controllers.BaseController
import be.simplenotes.app.controllers.NoteController
import be.simplenotes.app.controllers.SettingsController
import be.simplenotes.app.controllers.UserController
import be.simplenotes.app.filters.ImmutableFilter
import be.simplenotes.app.filters.SecurityFilter
import be.simplenotes.app.filters.jwtPayload
import be.simplenotes.app.controllers.*
import be.simplenotes.app.filters.*
import be.simplenotes.domain.security.JwtPayload
import org.http4k.core.*
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.routing.*
import org.http4k.routing.ResourceLoader.Companion.Classpath
class Router(
private val baseController: BaseController,
@@ -23,26 +19,26 @@ class Router(
private val settingsController: SettingsController,
private val apiUserController: ApiUserController,
private val apiNoteController: ApiNoteController,
private val healthCheckController: HealthCheckController,
private val requiredAuth: Filter,
private val optionalAuth: Filter,
private val errorFilter: Filter,
private val apiAuth: Filter,
private val errorFilter: ErrorFilter,
private val transactionFilter: TransactionFilter,
private val contexts: RequestContexts,
) {
operator fun invoke(): RoutingHttpHandler {
val resourceLoader = ResourceLoader.Classpath(("/static"))
val basicRoutes = routes(
ImmutableFilter().then(static(resourceLoader, "woff2" to ContentType("font/woff2"))),
val basicRoutes =
routes(
"/health" bind GET to healthCheckController::healthCheck,
ImmutableFilter.then(static(Classpath("/static"), "woff2" to ContentType("font/woff2")))
)
infix fun PathMethod.public(handler: PublicHandler) = this to { handler(it, it.jwtPayload(contexts)) }
infix fun PathMethod.protected(handler: ProtectedHandler) = this to { handler(it, it.jwtPayload(contexts)!!) }
val publicRoutes: RoutingHttpHandler = routes(
val publicRoutes = routes(
"/" bind GET public baseController::index,
"/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 POST public userController::login,
"/logout" bind POST to userController::logout,
@@ -51,18 +47,18 @@ class Router(
val protectedRoutes = routes(
"/settings" bind GET protected settingsController::settings,
"/settings" bind POST protected settingsController::settings,
"/settings" bind POST transactional settingsController::settings,
"/export" bind POST protected settingsController::export,
"/notes" bind GET protected noteController::list,
"/notes" bind POST protected noteController::search,
"/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/{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 POST protected noteController::edit,
"/notes/deleted/{uuid}" bind POST protected noteController::deleted,
"/notes/{uuid}/edit" bind POST transactional noteController::edit,
"/notes/deleted/{uuid}" bind POST transactional noteController::deleted,
)
val apiRoutes = routes(
@@ -71,10 +67,10 @@ class Router(
val protectedApiRoutes = routes(
"/api/notes" bind GET protected apiNoteController::notes,
"/api/notes" bind POST protected apiNoteController::createNote,
"/api/notes/search" bind POST protected apiNoteController::search,
"/api/notes" bind POST transactional apiNoteController::createNote,
"/api/notes/search" bind POST transactional apiNoteController::search,
"/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(
@@ -87,11 +83,23 @@ class Router(
val globalFilters = errorFilter
.then(InitialiseRequestContext(contexts))
.then(SecurityFilter())
.then(ResponseFilters.GZip())
.then(SecurityFilter)
.then(GZip())
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
@@ -0,0 +1,22 @@
package be.simplenotes.app.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.time.LocalDateTime
internal class LocalDateTimeSerializer : KSerializer<LocalDateTime> {
override val descriptor: SerialDescriptor
get() = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: LocalDateTime) {
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): LocalDateTime {
TODO("Not implemented, isn't needed")
}
}
@@ -0,0 +1,22 @@
package be.simplenotes.app.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.util.*
internal class UuidSerializer : KSerializer<UUID> {
override val descriptor: SerialDescriptor
get() = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: UUID) {
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): UUID {
TODO()
}
}
@@ -1,16 +1,17 @@
package be.simplenotes.app.utils
import kotlinx.serialization.json.*
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
interface StaticFileResolver {
fun resolve(name: String): String?
}
class StaticFileResolverImpl : StaticFileResolver {
class StaticFileResolverImpl(json: Json) : StaticFileResolver {
private val mappings: Map<String, String>
init {
val json = Json {}
val manifest = javaClass.getResource("/css-manifest.json").readText()
val manifestObject = json.parseToJsonElement(manifest).jsonObject
val keys = manifestObject.keys
+63 -70
View File
@@ -3,8 +3,7 @@ package be.simplenotes.app.views
import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.domain.security.JwtPayload
import kotlinx.html.*
import kotlinx.html.div
import org.intellij.lang.annotations.Language
import kotlinx.html.ThScope.col
class BaseView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
fun renderHome(jwtPayload: JwtPayload?) = renderPage(
@@ -21,74 +20,70 @@ class BaseView(staticFileResolver: StaticFileResolver) : View(staticFileResolver
div("container mx-auto flex flex-wrap justify-center content-center") {
unsafe {
@Language("html")
val html =
"""
<div aria-label="demo" class="md:order-1 order-2 flipped p-4 my-10 w-full md:w-1/2">
<div class="flex justify-between mb-4">
<h1 class="text-2xl underline">Notes</h1>
<span>
<span class="btn btn-teal pointer-events-none">Trash (3)</span>
<span class="ml-2 btn btn-green pointer-events-none">New</span>
</span>
</div>
<form class="md:space-x-2" id="search">
<input aria-label="demo-search" name="search" disabled="" value="tag:&quot;demo&quot;">
<span id="buttons">
<button type="button" disabled="" class="btn btn-green pointer-events-none">search</button>
<span class="btn btn-red pointer-events-none">clear</span>
</span>
</form>
<div class="overflow-x-auto">
<table id="notes">
<thead>
<tr>
<th scope="col" class="w-1/2">Title</th>
<th scope="col" class="w-1/4">Updated</th>
<th scope="col" class="w-1/4">Tags</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="text-blue-200 font-semibold underline">Formula 1</span></td>
<td class="text-center">moments ago</td>
<td>
<ul class="inline flex flex-wrap justify-center">
<li class="mx-2 my-1"><span class="tag disabled">#demo</span ></li>
</ul>
</td>
</tr>
<tr>
<td><span class="text-blue-200 font-semibold underline">Syntax highlighting</span></td>
<td class="text-center">2 hours ago</td>
<td>
<ul class="inline flex flex-wrap justify-center">
<li class="mx-2 my-1"><span class="tag disabled">#features</span></li>
<li class="mx-2 my-1"><span class="tag disabled">#demo</span></li>
</ul>
</td>
</tr>
<tr>
<td><span class="text-blue-200 font-semibold underline">report</span></td>
<td class="text-center">5 days ago</td>
<td>
<ul class="inline flex flex-wrap justify-center">
<li class="mx-2 my-1"><span class="tag disabled">#study</span></li>
<li class="mx-2 my-1"><span class="tag disabled">#demo</span></li>
</ul>
</td>
</tr>
</tbody>
</table>
</div>
</div>
""".trimIndent()
+html
div("md:order-1 order-2 flipped p-4 my-10 w-full md:w-1/2") {
attributes["aria-label"] = "demo"
div("flex justify-between mb-4") {
h1("text-2xl underline") { +"Notes" }
span {
span("btn btn-teal pointer-events-none") { +"Trash (3)" }
span("ml-2 btn btn-green pointer-events-none") { +"New" }
}
}
form(classes = "md:space-x-2") {
id = "search"
input {
attributes["aria-label"] = "demo-search"
attributes["name"] = "search"
attributes["disabled"] = ""
attributes["value"] = "tag:\"demo\""
}
span {
id = "buttons"
button(type = ButtonType.button, classes = "btn btn-green pointer-events-none") {
attributes["disabled"] = ""
+"search"
}
span("btn btn-red pointer-events-none") { +"clear" }
}
}
div("overflow-x-auto") {
demoTable()
}
}
welcome()
}
}
welcome()
@Suppress("NOTHING_TO_INLINE")
private inline fun DIV.demoTable() {
table {
id = "notes"
thead {
tr {
th(scope = col, classes = "w-1/2") { +"Title" }
th(scope = col, classes = "w-1/4") { +"Updated" }
th(scope = col, classes = "w-1/4") { +"Tags" }
}
}
tbody {
listOf(
Triple("Formula 1", "moments ago", arrayOf("#demo")),
Triple("Syntax highlighting", "2 hours ago", arrayOf("#features", "#demo")),
Triple("report", "5 days ago", arrayOf("#study", "#demo")),
).forEach { (title, ago, tags) ->
tr {
td { span("text-blue-200 font-semibold underline") { +title } }
td("text-center") { +ago }
td {
ul("inline flex flex-wrap justify-center") {
tags.forEach { tag ->
li("mx-2 my-1") { span("tag disabled") { +tag } }
}
}
}
}
}
}
}
}
@@ -96,7 +91,6 @@ class BaseView(staticFileResolver: StaticFileResolver) : View(staticFileResolver
private inline fun DIV.welcome() {
div("w-full my-auto md:w-1/2 md:order-2 order-1 text-center") {
div("m-4 rounded-lg p-6") {
p("text-teal-400") {
h2("text-3xl text-teal-400 underline") { +"Features:" }
ul("list-disc text-lg list-inside") {
li { +"Markdown support" }
@@ -111,5 +105,4 @@ class BaseView(staticFileResolver: StaticFileResolver) : View(staticFileResolver
}
}
}
}
}
+22 -5
View File
@@ -26,14 +26,31 @@ class SettingView(staticFileResolver: StaticFileResolver) : View(staticFileResol
}
}
section("m-4 p-4 bg-gray-800 rounded") {
p(classes = "mb-4") {
+"Export all my data"
section("m-4 p-4 bg-gray-800 rounded flex justify-around") {
form(method = FormMethod.post, action = "/export") {
button(name = "display",
classes = "inline btn btn-teal block",
type = submit) { +"Display my data" }
}
form(method = FormMethod.post, action = "/export") {
button(name = "display", classes = "inline btn btn-teal block", type = submit) { +"Display my data" }
button(name = "download", classes = "inline btn btn-green block ml-2 mt-2", type = submit) {
listOf("json", "zip").forEach { format ->
div {
radioInput(name = "format") {
id = format
attributes["value"] = format
if(format == "json") attributes["checked"] = ""
}
label(classes = "ml-2") {
attributes["for"] = format
+format
}
}
}
button(name = "download", classes = "inline btn btn-green block mt-2", type = submit) {
+"Download my data"
}
}
+12 -1
View File
@@ -31,7 +31,7 @@ abstract class View(staticFileResolver: StaticFileResolver) {
attributes["crossorigin"] = "anonymous"
}
link(rel = "stylesheet", href = styles)
link(rel = "shortcut icon", href = "/favicon.ico", type = "image/x-icon")
icons()
scripts.forEach { src ->
script(src = src) {}
}
@@ -42,4 +42,15 @@ abstract class View(staticFileResolver: StaticFileResolver) {
}
}
}
@Suppress("NOTHING_TO_INLINE")
private inline fun HEAD.icons() {
link(rel = "apple-touch-icon", href = "/apple-touch-icon.png") { attributes["sizes"] = "180x180" }
link(rel = "icon", href = "/favicon-32x32.png", type = "image/png") { attributes["sizes"] = "32x32" }
link(rel = "icon", href = "/favicon-16x16.png", type = "image/png") { attributes["sizes"] = "16x16" }
link(rel = "manifest", href = "/site.webmanifest")
link(rel = "mask-icon", href = "/safari-pinned-tab.svg") { attributes["color"] = "#2c7a7b" }
meta(name = "msapplication-TileColor", content = "#00aba9")
meta(name = "theme-color", content = "#2c7a7b")
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#00aba9</TileColor>
</tile>
</msapplication>
</browserconfig>
Binary file not shown.

After

Width:  |  Height:  |  Size: 814 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,33 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M1255 6993 c-179 -23 -313 -62 -461 -133 -396 -187 -665 -533 -766
-981 l-23 -104 0 -2275 0 -2275 23 -102 c125 -565 530 -970 1095 -1095 l102
-23 2275 0 2275 0 102 23 c565 125 970 530 1095 1095 l23 102 0 2275 0 2275
-23 102 c-125 566 -521 964 -1090 1095 l-97 22 -2250 2 c-1237 1 -2263 -1
-2280 -3z m1024 -1979 c128 -18 287 -70 394 -127 262 -139 448 -395 472 -649
3 -34 8 -78 11 -95 l5 -33 -255 0 -254 0 -6 28 c-2 15 -7 44 -11 65 -27 168
-204 335 -416 393 -94 26 -317 24 -421 -4 -218 -59 -345 -196 -356 -384 -6
-105 16 -173 80 -243 81 -88 227 -148 563 -230 509 -123 742 -228 916 -412
123 -131 170 -248 176 -448 9 -245 -55 -420 -212 -580 -162 -165 -387 -270
-667 -311 -154 -22 -461 -15 -595 15 -280 62 -513 193 -662 373 -72 85 -162
262 -185 358 -8 36 -18 95 -22 133 l-6 67 254 0 255 0 6 -53 c22 -179 135
-332 310 -417 127 -61 195 -74 387 -75 195 0 279 16 399 76 179 89 260 234
231 415 -22 137 -98 231 -243 302 -109 55 -202 84 -447 142 -237 57 -306 76
-427 120 -340 125 -535 303 -600 550 -23 87 -23 271 0 360 87 335 409 595 827
665 108 19 371 18 499 -1z m3170 0 c202 -35 325 -95 453 -224 77 -77 93 -100
141 -201 30 -63 63 -143 72 -179 49 -183 48 -159 52 -1307 l4 -1083 -256 0
-255 0 0 1034 c0 1160 1 1133 -70 1282 -92 192 -259 271 -548 262 -117 -4
-149 -9 -221 -33 -177 -60 -340 -200 -440 -376 l-31 -56 0 -1056 0 -1057 -255
0 -255 0 0 1480 0 1480 239 0 238 0 6 -82 c4 -46 7 -120 7 -165 0 -45 3 -88 6
-97 4 -11 28 8 92 72 163 164 356 267 575 307 103 19 334 18 446 -1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,19 @@
{
"name": "SimpleNotes",
"short_name": "SimpleNotes",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
@@ -27,8 +27,8 @@ internal class AuthFilterTest {
private val simpleJwt = SimpleJwt(jwtConfig)
private val extractor = JwtPayloadExtractor(simpleJwt)
private val ctx = RequestContexts()
private val requiredAuth = AuthFilter(extractor, AuthType.Required, ctx)()
private val optionalAuth = AuthFilter(extractor, AuthType.Optional, ctx)()
private val requiredAuth = AuthFilter(extractor, AuthType.Required, ctx)
private val optionalAuth = AuthFilter(extractor, AuthType.Optional, ctx)
private val echoJwtPayloadHandler = { request: Request -> Response(OK).body(request.jwtPayload(ctx).toString()) }
+5 -1
View File
@@ -11,7 +11,11 @@ simplenotes.be {
import strict-transport
header -Server
reverse_proxy http://localhost:8080
reverse_proxy http://localhost:8080 {
health_path /health
health_interval 5s
health_timeout 200ms
}
}
dev.simplenotes.be {
+10 -2
View File
@@ -19,8 +19,10 @@ services:
volumes:
- notes-db-volume:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
timeout: 10s
test: "mysql --protocol=tcp -u simplenotes -p$MYSQL_PASSWORD -e 'show databases'"
interval: 5s
timeout: 1s
start_period: 2s
retries: 10
simplenotes:
@@ -39,6 +41,12 @@ services:
# - PASSWORD
ports:
- 127.0.0.1:8080:8080
healthcheck:
test: "curl --fail -s http://localhost:8080/health"
interval: 5s
timeout: 1s
start_period: 2s
retries: 3
depends_on:
db:
condition: service_healthy
+33 -2
View File
@@ -1,4 +1,6 @@
<project>
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>parent</artifactId>
<groupId>be.simplenotes</groupId>
@@ -22,6 +24,30 @@
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.arrow-kt</groupId>
<artifactId>arrow-core</artifactId>
</dependency>
<dependency>
<groupId>org.koin</groupId>
<artifactId>koin-core</artifactId>
</dependency>
<dependency>
<groupId>com.natpryce</groupId>
<artifactId>hamkrest</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.mockk</groupId>
<artifactId>mockk</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.konform</groupId>
<artifactId>konform-jvm</artifactId>
@@ -59,7 +85,12 @@
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-serialization-runtime</artifactId>
<artifactId>kotlinx-serialization-json-jvm</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
<version>1.20</version>
</dependency>
</dependencies>
+8 -3
View File
@@ -21,12 +21,17 @@ import org.koin.dsl.module
val domainModule = module {
single<LoginUseCase> { LoginUseCaseImpl(get(), get(), get()) }
single<RegisterUseCase> { RegisterUseCaseImpl(get(), get()) }
single<DeleteUseCase> { DeleteUseCaseImpl(get(), get()) }
single<DeleteUseCase> { DeleteUseCaseImpl(get(), get(), get()) }
single { UserService(get(), get(), get(), get()) }
single<PasswordHash> { BcryptPasswordHash() }
single { SimpleJwt(get()) }
single { JwtPayloadExtractor(get()) }
single { NoteService(get(), get(), get(), get()) }
single {
NoteService(get(), get(), get(), get()).apply {
dropAllIndexes()
indexAll()
}
}
single<MarkdownConverter> { MarkdownConverterImpl() }
single<ExportUseCase> { ExportUseCaseImpl(get()) }
single<ExportUseCase> { ExportUseCaseImpl(get(), get()) }
}
@@ -12,7 +12,6 @@ import be.simplenotes.domain.usecases.repositories.NoteRepository
import be.simplenotes.domain.usecases.repositories.UserRepository
import be.simplenotes.domain.usecases.search.NoteSearcher
import be.simplenotes.domain.usecases.search.SearchTerms
import kotlinx.serialization.Serializable
import java.util.*
class NoteService(
@@ -1,5 +1,8 @@
package be.simplenotes.domain.usecases.export
import java.io.InputStream
interface ExportUseCase {
fun export(userId: Int): String
fun exportAsJson(userId: Int): String
fun exportAsZip(userId: Int): InputStream
}
@@ -2,42 +2,49 @@ package be.simplenotes.domain.usecases.export
import be.simplenotes.domain.model.ExportedNote
import be.simplenotes.domain.usecases.repositories.NoteRepository
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import java.time.LocalDateTime
internal class ExportUseCaseImpl(private val noteRepository: NoteRepository) : ExportUseCase {
override fun export(userId: Int): String {
val module = SerializersModule {
contextual(LocalDateTime::class, LocalDateTimeSerializer)
}
val json = Json {
prettyPrint = true
serializersModule = module
}
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
internal class ExportUseCaseImpl(private val noteRepository: NoteRepository, private val json: Json) : ExportUseCase {
override fun exportAsJson(userId: Int): String {
val notes = noteRepository.export(userId)
return json.encodeToString(ListSerializer(ExportedNote.serializer()), notes)
}
}
internal object LocalDateTimeSerializer : KSerializer<LocalDateTime> {
override val descriptor: SerialDescriptor
get() = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
private fun sanitizeFilename(inputName: String): String = inputName.replace("[^a-zA-Z0-9-_.]".toRegex(), "_")
override fun serialize(encoder: Encoder, value: LocalDateTime) {
encoder.encodeString(value.toString())
override fun exportAsZip(userId: Int): InputStream {
val notes = noteRepository.export(userId)
val zipOutput = ZipOutput()
zipOutput.use { zip ->
notes.forEach {
val name = sanitizeFilename(it.title)
zip.write("notes/$name.md", it.markdown)
}
override fun deserialize(decoder: Decoder): LocalDateTime {
TODO("Not implemented, isn't needed")
}
return ByteArrayInputStream(zipOutput.outputStream.toByteArray())
}
}
class ZipOutput : AutoCloseable {
val outputStream = ByteArrayOutputStream()
private val zipOutputStream = ZipArchiveOutputStream(outputStream)
fun write(path: String, content: String) {
val entry = ZipArchiveEntry(path)
zipOutputStream.putArchiveEntry(entry)
zipOutputStream.write(content.toByteArray())
zipOutputStream.closeArchiveEntry()
}
override fun close() {
zipOutputStream.finish()
zipOutputStream.close()
}
}
@@ -5,12 +5,14 @@ import arrow.core.extensions.fx
import arrow.core.rightIfNotNull
import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.usecases.repositories.UserRepository
import be.simplenotes.domain.usecases.search.NoteSearcher
import be.simplenotes.domain.validation.UserValidations
internal class DeleteUseCaseImpl(
private val userRepository: UserRepository,
private val passwordHash: PasswordHash,
) : DeleteUseCase {
private val searcher: NoteSearcher,
) : DeleteUseCase {
override fun delete(form: DeleteForm) = Either.fx<DeleteError, Unit> {
val user = !UserValidations.validateDelete(form)
val persistedUser = !userRepository.find(user.username).rightIfNotNull { DeleteError.Unregistered }
@@ -20,5 +22,6 @@ internal class DeleteUseCaseImpl(
{ DeleteError.WrongPassword }
)
!Either.cond(userRepository.delete(persistedUser.id), { Unit }, { DeleteError.Unregistered })
searcher.dropIndex(persistedUser.id)
}
}
+18 -3
View File
@@ -1,4 +1,6 @@
<project>
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>parent</artifactId>
<groupId>be.simplenotes</groupId>
@@ -9,6 +11,10 @@
<artifactId>persistance</artifactId>
<dependencies>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
</dependency>
<dependency>
<groupId>be.simplenotes</groupId>
<artifactId>domain</artifactId>
@@ -27,6 +33,17 @@
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
@@ -50,12 +67,10 @@
<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>
</dependencies>
@@ -0,0 +1,28 @@
package be.simplenotes.persistance
import be.simplenotes.persistance.utils.DbType
import be.simplenotes.persistance.utils.type
import be.simplenotes.shared.config.DataSourceConfig
import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.database.asIterable
import java.sql.SQLTransientException
interface DbHealthCheck {
fun isOk(): Boolean
}
internal class DbHealthCheckImpl(
private val db: Database,
private val dataSourceConfig: DataSourceConfig,
) : DbHealthCheck {
override fun isOk() = if (dataSourceConfig.type() == DbType.H2) true
else try {
db.useConnection { connection ->
connection.prepareStatement("""SHOW DATABASES""").use {
it.executeQuery().asIterable().map { it.getString(1) }
}
}.any { it in dataSourceConfig.jdbcUrl }
} catch (e: SQLTransientException) {
false
}
}
@@ -1,18 +1,24 @@
package be.simplenotes.persistance
import be.simplenotes.persistance.utils.DbType
import be.simplenotes.persistance.utils.type
import be.simplenotes.shared.config.DataSourceConfig
import org.flywaydb.core.Flyway
import javax.sql.DataSource
interface DbMigrations {
fun migrate()
}
internal class DbMigrationsImpl(
private val dataSource: DataSource,
private val dataSourceConfig: DataSourceConfig
private val dataSourceConfig: DataSourceConfig,
) : DbMigrations {
override fun migrate() {
val migrationDir = when {
dataSourceConfig.jdbcUrl.contains("mariadb") -> "db/migration/mariadb"
else -> "db/migration/other"
val migrationDir = when (dataSourceConfig.type()) {
DbType.H2 -> "db/migration/other"
DbType.MariaDb -> "db/migration/mariadb"
}
Flyway.configure()
@@ -2,19 +2,22 @@ package be.simplenotes.persistance
import be.simplenotes.domain.usecases.repositories.NoteRepository
import be.simplenotes.domain.usecases.repositories.UserRepository
import be.simplenotes.persistance.converters.NoteConverter
import be.simplenotes.persistance.converters.NoteConverterImpl
import be.simplenotes.persistance.converters.UserConverter
import be.simplenotes.persistance.converters.UserConverterImpl
import be.simplenotes.persistance.notes.NoteRepositoryImpl
import be.simplenotes.persistance.users.UserRepositoryImpl
import be.simplenotes.shared.config.DataSourceConfig
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import me.liuwj.ktorm.database.*
import me.liuwj.ktorm.database.Database
import org.koin.dsl.bind
import org.koin.dsl.module
import org.koin.dsl.onClose
import org.mapstruct.factory.Mappers
import javax.sql.DataSource
interface DbMigrations {
fun migrate()
}
private fun hikariDataSource(conf: DataSourceConfig): HikariDataSource {
val hikariConfig = HikariConfig().also {
it.jdbcUrl = conf.jdbcUrl
@@ -27,10 +30,19 @@ private fun hikariDataSource(conf: DataSourceConfig): HikariDataSource {
return HikariDataSource(hikariConfig)
}
val persistanceModule = module {
single<UserRepository> { UserRepositoryImpl(get()) }
single<NoteRepository> { NoteRepositoryImpl(get()) }
val migrationModule = module {
single<DbMigrations> { DbMigrationsImpl(get(), get()) }
single<DataSource> { hikariDataSource(get()) }
single { Database.connect(get<DataSource>()) }
}
val persistanceModule = module {
single<NoteConverter> { NoteConverterImpl() }
single<UserConverter> { UserConverterImpl() }
single<UserRepository> { UserRepositoryImpl(get(), get()) }
single<NoteRepository> { NoteRepositoryImpl(get(), get()) }
single { hikariDataSource(get()) } bind DataSource::class onClose { it?.close() }
single {
get<DbMigrations>().migrate()
Database.connect(get<DataSource>())
}
single<DbHealthCheck> { DbHealthCheckImpl(get(), get()) }
}
@@ -0,0 +1,80 @@
package be.simplenotes.persistance.converters
import be.simplenotes.domain.model.*
import be.simplenotes.persistance.notes.NoteEntity
import me.liuwj.ktorm.entity.Entity
import org.mapstruct.Mapper
import org.mapstruct.Mapping
import org.mapstruct.Mappings
import java.time.LocalDateTime
import java.util.*
/**
* This is an abstract class because kotlin default methods in interface are not seen as default in kapt
* @see [KT-25960](https://youtrack.jetbrains.com/issue/KT-25960)
*/
@Mapper(uses = [NoteEntityFactory::class, UserEntityFactory::class])
internal abstract class NoteConverter {
fun toNote(entity: NoteEntity, tags: Tags) =
Note(NoteMetadata(title = entity.title, tags = tags), entity.markdown, entity.html)
@Mappings(
Mapping(target = ".", source = "entity"),
Mapping(target = "tags", source = "tags"),
)
abstract fun toNoteMetadata(entity: NoteEntity, tags: Tags): NoteMetadata
@Mappings(
Mapping(target = ".", source = "entity"),
Mapping(target = "tags", source = "tags"),
)
abstract fun toPersistedNoteMetadata(entity: NoteEntity, tags: Tags): PersistedNoteMetadata
fun toPersistedNote(entity: NoteEntity, tags: Tags) = PersistedNote(
NoteMetadata(title = entity.title, tags = tags),
entity.markdown, entity.html, entity.updatedAt, entity.uuid, entity.public
)
@Mappings(
Mapping(target = ".", source = "entity"),
Mapping(target = "trash", source = "entity.deleted"),
Mapping(target = "tags", source = "tags"),
)
abstract fun toExportedNote(entity: NoteEntity, tags: Tags): ExportedNote
fun toEntity(note: Note, uuid: UUID, userId: Int, updatedAt: LocalDateTime) = NoteEntity {
this.title = note.meta.title
this.markdown = note.markdown
this.html = note.html
this.uuid = uuid
this.deleted = false
this.public = false
this.user.id = userId
this.updatedAt = updatedAt
}
@Mappings(
Mapping(target = ".", source = "note"),
Mapping(target = "updatedAt", source = "updatedAt"),
Mapping(target = "uuid", source = "uuid"),
Mapping(target = "public", constant = "false"),
)
abstract fun toPersistedNote(note: Note, updatedAt: LocalDateTime, uuid: UUID): PersistedNote
abstract fun toEntity(persistedNoteMetadata: PersistedNoteMetadata): NoteEntity
abstract fun toEntity(noteMetadata: NoteMetadata): NoteEntity
@Mapping(target = "title", source = "meta.title")
abstract fun toEntity(persistedNote: PersistedNote): NoteEntity
@Mapping(target = "deleted", source = "trash")
abstract fun toEntity(exportedNote: ExportedNote): NoteEntity
}
typealias Tags = List<String>
internal class NoteEntityFactory : Entity.Factory<NoteEntity>()
@@ -0,0 +1,16 @@
package be.simplenotes.persistance.converters
import be.simplenotes.domain.model.PersistedUser
import be.simplenotes.domain.model.User
import be.simplenotes.persistance.users.UserEntity
import me.liuwj.ktorm.entity.Entity
import org.mapstruct.Mapper
@Mapper(uses = [UserEntityFactory::class])
internal interface UserConverter {
fun convertToUser(userEntity: UserEntity): User
fun convertToPersistedUser(userEntity: UserEntity): PersistedUser
fun convertToEntity(user: User): UserEntity
}
internal class UserEntityFactory : Entity.Factory<UserEntity>()
@@ -1,7 +1,11 @@
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.persistance.converters.NoteConverter
import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.dsl.*
import me.liuwj.ktorm.entity.*
@@ -9,7 +13,7 @@ import java.time.LocalDateTime
import java.util.*
import kotlin.collections.HashMap
internal class NoteRepositoryImpl(private val db: Database) : NoteRepository {
internal class NoteRepositoryImpl(private val db: Database, private val converter: NoteConverter) : NoteRepository {
@Throws(IllegalArgumentException::class)
override fun findAll(
@@ -17,7 +21,7 @@ internal class NoteRepositoryImpl(private val db: Database) : NoteRepository {
limit: Int,
offset: Int,
tag: String?,
deleted: Boolean
deleted: Boolean,
): List<PersistedNoteMetadata> {
require(limit > 0) { "limit should be positive" }
require(offset >= 0) { "offset should not be negative" }
@@ -46,7 +50,7 @@ internal class NoteRepositoryImpl(private val db: Database) : NoteRepository {
return notes.map { note ->
val tags = tagsByUuid[note.uuid] ?: emptyList()
note.toPersistedMetadata(tags)
converter.toPersistedNoteMetadata(note, tags)
}
}
@@ -56,10 +60,7 @@ internal class NoteRepositoryImpl(private val db: Database) : NoteRepository {
override fun create(userId: Int, note: Note): PersistedNote {
val uuid = UUID.randomUUID()
val entity = note.toEntity(uuid, userId).apply {
this.updatedAt = LocalDateTime.now()
}
db.useTransaction {
val entity = converter.toEntity(note, uuid, userId, LocalDateTime.now())
db.notes.add(entity)
db.batchInsert(Tags) {
note.meta.tags.forEach { tagName ->
@@ -69,9 +70,7 @@ internal class NoteRepositoryImpl(private val db: Database) : NoteRepository {
}
}
}
}
return entity.toPersistedNote(note.meta.tags)
return converter.toPersistedNote(entity, note.meta.tags)
}
override fun find(userId: Int, uuid: UUID): PersistedNote? {
@@ -86,12 +85,10 @@ internal class NoteRepositoryImpl(private val db: Database) : NoteRepository {
.where { Tags.noteUuid eq uuid }
.map { it[Tags.name]!! }
return note.toPersistedNote(tags)
return converter.toPersistedNote(note, tags)
}
override fun update(userId: Int, uuid: UUID, note: Note): PersistedNote? {
db.useTransaction {
val now = LocalDateTime.now()
val count = db.update(Notes) {
it.title to note.meta.title
@@ -116,39 +113,26 @@ internal class NoteRepositoryImpl(private val db: Database) : NoteRepository {
}
}
return PersistedNote(
meta = note.meta,
markdown = note.markdown,
html = note.html,
updatedAt = now,
uuid = uuid,
public = false, // TODO
)
}
return converter.toPersistedNote(note, now, uuid)
}
override fun delete(userId: Int, uuid: UUID, permanent: Boolean): Boolean {
return if (!permanent) {
db.useTransaction {
db.update(Notes) {
it.deleted to true
it.updatedAt to LocalDateTime.now()
where { it.userId eq userId and (it.uuid eq uuid) }
}
} == 1
} else db.useTransaction {
} else
db.delete(Notes) { it.uuid eq uuid and (it.userId eq userId) } == 1
}
}
override fun restore(userId: Int, uuid: UUID): Boolean {
return db.useTransaction {
db.update(Notes) {
return db.update(Notes) {
it.deleted to false
where { (it.userId eq userId) and (it.uuid eq uuid) }
} == 1
}
}
override fun getTags(userId: Int): List<String> =
db.from(Tags)
@@ -175,14 +159,8 @@ internal class NoteRepositoryImpl(private val db: Database) : NoteRepository {
val tagsByUuid = notes.tagsByUuid()
return notes.map { note ->
ExportedNote(
title = note.title,
tags = tagsByUuid[note.uuid] ?: emptyList(),
markdown = note.markdown,
html = note.html,
updatedAt = note.updatedAt,
trash = note.deleted,
)
val tags = tagsByUuid[note.uuid] ?: emptyList()
converter.toExportedNote(note, tags)
}
}
@@ -196,7 +174,7 @@ internal class NoteRepositoryImpl(private val db: Database) : NoteRepository {
return notes.map { note ->
val tags = tagsByUuid[note.uuid] ?: emptyList()
note.toPersistedNote(tags)
converter.toPersistedNote(note, tags)
}
}
@@ -223,7 +201,7 @@ internal class NoteRepositoryImpl(private val db: Database) : NoteRepository {
.where { Tags.noteUuid eq uuid }
.map { it[Tags.name]!! }
return note.toPersistedNote(tags)
return converter.toPersistedNote(note, tags)
}
private fun List<NoteEntity>.tagsByUuid(): Map<UUID, List<String>> {
+3 -24
View File
@@ -2,15 +2,12 @@
package be.simplenotes.persistance.notes
import be.simplenotes.domain.model.Note
import be.simplenotes.domain.model.NoteMetadata
import be.simplenotes.domain.model.PersistedNote
import be.simplenotes.domain.model.PersistedNoteMetadata
import be.simplenotes.persistance.extensions.uuidBinary
import be.simplenotes.persistance.users.UserEntity
import be.simplenotes.persistance.users.Users
import me.liuwj.ktorm.database.*
import me.liuwj.ktorm.entity.*
import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.entity.Entity
import me.liuwj.ktorm.entity.sequenceOf
import me.liuwj.ktorm.schema.*
import java.time.LocalDateTime
import java.util.*
@@ -46,21 +43,3 @@ internal interface NoteEntity : Entity<NoteEntity> {
var user: UserEntity
}
internal fun NoteEntity.toPersistedMetadata(tags: List<String>) = PersistedNoteMetadata(title, tags, updatedAt, uuid)
internal fun NoteEntity.toPersistedNote(tags: List<String>) =
PersistedNote(NoteMetadata(title, tags), markdown, html, updatedAt, uuid, public)
internal fun Note.toEntity(uuid: UUID, userId: Int): NoteEntity {
val note = this
return NoteEntity {
this.title = note.meta.title
this.markdown = note.markdown
this.html = note.html
this.uuid = uuid
this.deleted = false
this.public = false
this.user["id"] = userId
}
}
@@ -3,30 +3,35 @@ package be.simplenotes.persistance.users
import be.simplenotes.domain.model.PersistedUser
import be.simplenotes.domain.model.User
import be.simplenotes.domain.usecases.repositories.UserRepository
import me.liuwj.ktorm.database.*
import be.simplenotes.persistance.converters.UserConverter
import me.liuwj.ktorm.database.Database
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
internal class UserRepositoryImpl(private val db: Database) : UserRepository {
internal class UserRepositoryImpl(private val db: Database, private val converter: UserConverter) : UserRepository {
override fun create(user: User): PersistedUser? {
return try {
db.useTransaction {
val id = db.insertAndGenerateKey(Users) {
it.username to user.username
it.password to user.password
} as Int
PersistedUser(user.username, user.password, id)
}
} catch (e: SQLIntegrityConstraintViolationException) {
null
}
}
override fun find(username: String) = db.users.find { it.username eq username }?.toPersistedUser()
override fun find(id: Int) = db.users.find { it.id eq id }?.toPersistedUser()
override fun find(username: String) = db.users.find { it.username eq username }
?.let { converter.convertToPersistedUser(it) }
override fun find(id: Int) = db.users.find { it.id eq id }?.let {
converter.convertToPersistedUser(it)
}
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 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]!! }
}
+7 -7
View File
@@ -1,9 +1,11 @@
package be.simplenotes.persistance.users
import be.simplenotes.domain.model.PersistedUser
import me.liuwj.ktorm.database.*
import me.liuwj.ktorm.entity.*
import me.liuwj.ktorm.schema.*
import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.entity.Entity
import me.liuwj.ktorm.entity.sequenceOf
import me.liuwj.ktorm.schema.Table
import me.liuwj.ktorm.schema.int
import me.liuwj.ktorm.schema.varchar
internal open class Users(alias: String?) : Table<UserEntity>("Users", alias) {
companion object : Users(null)
@@ -18,11 +20,9 @@ internal open class Users(alias: String?) : Table<UserEntity>("Users", alias) {
internal interface UserEntity : Entity<UserEntity> {
companion object : Entity.Factory<UserEntity>()
val id: Int
var id: Int
var username: String
var password: String
}
internal fun UserEntity.toPersistedUser() = PersistedUser(username, password, id)
internal val Database.users get() = this.sequenceOf(Users, withReferences = false)
@@ -0,0 +1,8 @@
package be.simplenotes.persistance.utils
import be.simplenotes.shared.config.DataSourceConfig
enum class DbType { H2, MariaDb }
fun DataSourceConfig.type(): DbType = if (jdbcUrl.contains("mariadb")) DbType.MariaDb
else DbType.H2
@@ -0,0 +1,168 @@
package be.simplenotes.persistance.converters
import be.simplenotes.domain.model.*
import be.simplenotes.persistance.notes.NoteEntity
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.mapstruct.factory.Mappers
import java.time.LocalDateTime
import java.util.*
internal class NoteConverterTest {
@Nested
@DisplayName("Entity -> Models")
inner class EntityToModels {
@Test
fun `convert NoteEntity to Note`() {
val converter = Mappers.getMapper(NoteConverter::class.java)
val entity = NoteEntity {
title = "title"
markdown = "md"
html = "html"
}
val tags = listOf("a", "b")
val note = converter.toNote(entity, tags)
val expectedNote = Note(NoteMetadata(
title = "title",
tags = tags,
), markdown = "md", html = "html")
assertThat(note).isEqualTo(expectedNote)
}
@Test
fun `convert NoteEntity to ExportedNote`() {
val converter = Mappers.getMapper(NoteConverter::class.java)
val entity = NoteEntity {
title = "title"
markdown = "md"
html = "html"
updatedAt = LocalDateTime.MIN
deleted = true
}
val tags = listOf("a", "b")
val note = converter.toExportedNote(entity, tags)
val expectedNote = ExportedNote(
title = "title",
tags = tags,
markdown = "md",
html = "html",
updatedAt = LocalDateTime.MIN,
trash = true
)
assertThat(note).isEqualTo(expectedNote)
}
@Test
fun `convert NoteEntity to PersistedNoteMetadata`() {
val converter = Mappers.getMapper(NoteConverter::class.java)
val entity = NoteEntity {
uuid = UUID.randomUUID()
title = "title"
markdown = "md"
html = "html"
updatedAt = LocalDateTime.MIN
deleted = true
}
val tags = listOf("a", "b")
val note = converter.toPersistedNoteMetadata(entity, tags)
val expectedNote = PersistedNoteMetadata(
title = "title",
tags = tags,
updatedAt = LocalDateTime.MIN,
uuid = entity.uuid
)
assertThat(note).isEqualTo(expectedNote)
}
}
@Nested
@DisplayName("Models -> Entity")
inner class ModelsToEntity {
@Test
fun `convert Note to NoteEntity`() {
val converter = Mappers.getMapper(NoteConverter::class.java)
val note = Note(NoteMetadata("title", emptyList()), "md", "html")
val entity = converter.toEntity(note, UUID.randomUUID(), 2, LocalDateTime.MIN)
assertThat(entity)
.hasFieldOrPropertyWithValue("markdown", "md")
.hasFieldOrPropertyWithValue("html", "html")
.hasFieldOrPropertyWithValue("title", "title")
.hasFieldOrPropertyWithValue("uuid", entity.uuid)
.hasFieldOrPropertyWithValue("updatedAt", LocalDateTime.MIN)
}
@Test
fun `convert PersistedNoteMetadata to NoteEntity`() {
val converter = Mappers.getMapper(NoteConverter::class.java)
val persistedNoteMetadata =
PersistedNoteMetadata("title", emptyList(), LocalDateTime.MIN, UUID.randomUUID())
val entity = converter.toEntity(persistedNoteMetadata)
assertThat(entity)
.hasFieldOrPropertyWithValue("uuid", persistedNoteMetadata.uuid)
.hasFieldOrPropertyWithValue("updatedAt", persistedNoteMetadata.updatedAt)
.hasFieldOrPropertyWithValue("title", "title")
}
@Test
fun `convert NoteMetadata to NoteEntity`() {
val converter = Mappers.getMapper(NoteConverter::class.java)
val noteMetadata = NoteMetadata("title", emptyList())
val entity = converter.toEntity(noteMetadata)
assertThat(entity)
.hasFieldOrPropertyWithValue("title", "title")
}
@Test
fun `convert PersistedNote to NoteEntity`() {
val converter = Mappers.getMapper(NoteConverter::class.java)
val persistedNote = PersistedNote(
NoteMetadata("title", emptyList()),
markdown = "md",
html = "html",
updatedAt = LocalDateTime.MIN,
uuid = UUID.randomUUID(),
public = true
)
val entity = converter.toEntity(persistedNote)
assertThat(entity)
.hasFieldOrPropertyWithValue("title", "title")
.hasFieldOrPropertyWithValue("markdown", "md")
.hasFieldOrPropertyWithValue("uuid", persistedNote.uuid)
.hasFieldOrPropertyWithValue("updatedAt", persistedNote.updatedAt)
.hasFieldOrPropertyWithValue("deleted", false)
.hasFieldOrPropertyWithValue("public", true)
}
@Test
fun `convert ExportedNote to NoteEntity`() {
val converter = Mappers.getMapper(NoteConverter::class.java)
val exportedNote = ExportedNote(
"title",
emptyList(),
markdown = "md",
html = "html",
updatedAt = LocalDateTime.MIN,
trash = true
)
val entity = converter.toEntity(exportedNote)
assertThat(entity)
.hasFieldOrPropertyWithValue("title", "title")
.hasFieldOrPropertyWithValue("markdown", "md")
.hasFieldOrPropertyWithValue("updatedAt", exportedNote.updatedAt)
.hasFieldOrPropertyWithValue("deleted", true)
}
}
}
@@ -0,0 +1,48 @@
package be.simplenotes.persistance.converters
import be.simplenotes.domain.model.PersistedUser
import be.simplenotes.domain.model.User
import be.simplenotes.persistance.users.UserEntity
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.mapstruct.factory.Mappers
internal class UserConverterTest {
@Test
fun `convert UserEntity to User`() {
val converter = Mappers.getMapper(UserConverter::class.java)
val entity = UserEntity {
username = "test"
password = "test2"
}.apply {
this["id"] = 2
}
val user = converter.convertToUser(entity)
assertThat(user).isEqualTo(User("test", "test2"))
}
@Test
fun `convert UserEntity to PersistedUser`() {
val converter = Mappers.getMapper(UserConverter::class.java)
val entity = UserEntity {
username = "test"
password = "test2"
}.apply {
this["id"] = 2
}
val user = converter.convertToPersistedUser(entity)
assertThat(user).isEqualTo(PersistedUser("test", "test2", 2))
}
@Test
fun `convert User to UserEntity`() {
val converter = Mappers.getMapper(UserConverter::class.java)
val user = User("test", "test2")
val entity = converter.convertToEntity(user)
assertThat(entity)
.hasFieldOrPropertyWithValue("username", "test")
.hasFieldOrPropertyWithValue("password", "test2")
}
}
@@ -4,11 +4,16 @@ import be.simplenotes.domain.model.*
import be.simplenotes.domain.usecases.repositories.NoteRepository
import be.simplenotes.domain.usecases.repositories.UserRepository
import be.simplenotes.persistance.DbMigrations
import be.simplenotes.persistance.converters.NoteConverter
import be.simplenotes.persistance.migrationModule
import be.simplenotes.persistance.persistanceModule
import be.simplenotes.shared.config.DataSourceConfig
import me.liuwj.ktorm.database.*
import me.liuwj.ktorm.dsl.*
import me.liuwj.ktorm.entity.*
import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.dsl.eq
import me.liuwj.ktorm.entity.filter
import me.liuwj.ktorm.entity.find
import me.liuwj.ktorm.entity.mapColumns
import me.liuwj.ktorm.entity.toList
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.flywaydb.core.Flyway
@@ -16,6 +21,7 @@ import org.junit.jupiter.api.*
import org.junit.jupiter.api.parallel.ResourceLock
import org.koin.dsl.koinApplication
import org.koin.dsl.module
import org.mapstruct.factory.Mappers
import java.sql.SQLIntegrityConstraintViolationException
import java.util.*
import javax.sql.DataSource
@@ -27,7 +33,7 @@ internal class NoteRepositoryImplTest {
}
private val koinApp = koinApplication {
modules(persistanceModule, testModule)
modules(persistanceModule, migrationModule, testModule)
}
private fun dataSourceConfig() = DataSourceConfig(
@@ -185,10 +191,12 @@ internal class NoteRepositoryImplTest {
fun `find an existing note`() {
createNote(user1.id, "1", listOf("a", "b"))
val converter = Mappers.getMapper(NoteConverter::class.java)
val note = db.notes.find { it.title eq "1" }!!
.let { entity ->
val tags = db.tags.filter { it.noteUuid eq entity.uuid }.mapColumns { it.name } as List<String>
entity.toPersistedNote(tags)
converter.toPersistedNote(entity, tags)
}
assertThat(noteRepo.find(user1.id, note.uuid))
@@ -3,6 +3,7 @@ package be.simplenotes.persistance.users
import be.simplenotes.domain.model.User
import be.simplenotes.domain.usecases.repositories.UserRepository
import be.simplenotes.persistance.DbMigrations
import be.simplenotes.persistance.migrationModule
import be.simplenotes.persistance.persistanceModule
import be.simplenotes.shared.config.DataSourceConfig
import me.liuwj.ktorm.database.*
@@ -28,7 +29,7 @@ internal class UserRepositoryImplTest {
}
private val koinApp = koinApplication {
modules(persistanceModule, testModule)
modules(persistanceModule, migrationModule, testModule)
}
private fun dataSourceConfig() = DataSourceConfig(
+124 -96
View File
@@ -1,4 +1,6 @@
<project>
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>be.simplenotes</groupId>
@@ -17,7 +19,7 @@
<properties>
<java.version>14</java.version>
<kotlin.version>1.4.0</kotlin.version>
<kotlin.version>1.4.10</kotlin.version>
<junit.version>5.6.2</junit.version>
<kotlin.code.style>official</kotlin.code.style>
@@ -27,6 +29,8 @@
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
<kotlin.compiler.jvmTarget>${java.version}</kotlin.compiler.jvmTarget>
<org.mapstruct.version>1.4.1.Final</org.mapstruct.version>
</properties>
<dependencies>
@@ -35,101 +39,83 @@
<artifactId>kotlin-stdlib-jdk8</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.koin</groupId>
<artifactId>koin-core</artifactId>
<version>2.1.6</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.3</version>
</dependency>
<dependency>
<groupId>io.arrow-kt</groupId>
<artifactId>arrow-core</artifactId>
<version>0.10.5</version>
</dependency>
<!-- region tests -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-test-junit</artifactId>
<version>${kotlin.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.mockk</groupId>
<artifactId>mockk</artifactId>
<version>1.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.koin</groupId>
<artifactId>koin-test</artifactId>
<version>2.1.6</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.natpryce</groupId>
<artifactId>hamkrest</artifactId>
<version>1.7.0.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.16.1</version>
<scope>test</scope>
</dependency>
<!-- endregion -->
</dependencies>
<build>
<sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
<testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.2.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.4</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0-M4</version>
<version>3.0.0-M5</version>
<dependencies>
<dependency>
<groupId>org.apache.maven.surefire</groupId>
<artifactId>surefire-junit-platform</artifactId>
<version>3.0.0-M4</version>
<version>3.0.0-M5</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.1.2</version>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.0.0-M3</version>
<executions>
<execution>
<id>enforce</id>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<banDuplicatePomDependencyVersions/>
<requireMavenVersion>
<version>3.6</version>
</requireMavenVersion>
</rules>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>kotlin-maven-plugin</artifactId>
<groupId>org.jetbrains.kotlin</groupId>
<version>${kotlin.version}</version>
<executions>
<execution>
<id>kapt</id>
<goals>
<goal>kapt</goal>
</goals>
<configuration>
<annotationProcessorPaths>
<annotationProcessorPath>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</annotationProcessorPath>
</annotationProcessorPaths>
</configuration>
</execution>
<execution>
<id>compile</id>
<phase>process-sources</phase>
@@ -165,39 +151,81 @@
</plugins>
</build>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk8</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk7</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-common</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-reflect</artifactId>
<artifactId>kotlin-bom</artifactId>
<version>${kotlin.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-serialization-runtime</artifactId>
<version>1.0-M1-1.4.0-rc</version>
<artifactId>kotlinx-serialization-json-jvm</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.koin</groupId>
<artifactId>koin-core</artifactId>
<version>2.1.6</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.3</version>
</dependency>
<dependency>
<groupId>io.arrow-kt</groupId>
<artifactId>arrow-core</artifactId>
<version>0.10.5</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.25</version>
</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>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
<!-- region tests -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
</dependency>
<dependency>
<groupId>io.mockk</groupId>
<artifactId>mockk</artifactId>
<version>1.10.0</version>
</dependency>
<dependency>
<groupId>com.natpryce</groupId>
<artifactId>hamkrest</artifactId>
<version>1.7.0.3</version>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.16.1</version>
</dependency>
<!-- endregion -->
</dependencies>
</dependencyManagement>
+16
View File
@@ -37,6 +37,22 @@
<version>${lucene.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>be.simplenotes</groupId>
<artifactId>shared</artifactId>
+16 -2
View File
@@ -1,4 +1,6 @@
<project>
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>parent</artifactId>
<groupId>be.simplenotes</groupId>
@@ -8,12 +10,24 @@
<artifactId>shared</artifactId>
<dependencies>
<dependency>
<groupId>io.arrow-kt</groupId>
<artifactId>arrow-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.natpryce</groupId>
<artifactId>hamkrest</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>2.6</version>
<executions>
<execution>
<goals>