Compare commits

...

5 Commits

77 changed files with 1063 additions and 571 deletions

5
.sdkmanrc Normal file
View File

@ -0,0 +1,5 @@
# Enable auto-env through the sdkman_auto_env config
# Add key=value pairs of SDKs to use below
java=14.0.2-open
gradle=6.7
kotlin=1.4.10

View File

@ -16,7 +16,6 @@ object Libs {
const val jbcrypt = "org.mindrot:jbcrypt:0.4"
const val jettyServer = "org.eclipse.jetty:jetty-server:9.4.32.v20200930"
const val jettyServlet = "org.eclipse.jetty:jetty-servlet:9.4.32.v20200930"
const val koinCore = "org.koin:koin-core:2.1.6"
const val konform = "io.konform:konform-jvm:0.2.0"
const val kotlinxHtml = "org.jetbrains.kotlinx:kotlinx-html-jvm:0.7.1"
const val kotlinxSerializationJson = "org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.0.0"
@ -28,6 +27,8 @@ object Libs {
const val luceneQueryParser = "org.apache.lucene:lucene-queryparser:8.6.1"
const val mapstruct = "org.mapstruct:mapstruct:1.4.1.Final"
const val mapstructProcessor = "org.mapstruct:mapstruct-processor:1.4.1.Final"
const val micronaut = "io.micronaut:micronaut-inject:2.1.2"
const val micronautProcessor = "io.micronaut:micronaut-inject-java:2.1.2"
const val mariadbClient = "org.mariadb.jdbc:mariadb-java-client:2.6.2"
const val owaspHtmlSanitizer = "com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20200713.1"
const val prettytime ="org.ocpsoft.prettytime:prettytime:4.0.5.Final"
@ -39,4 +40,6 @@ object Libs {
const val http4kTestingHamkrest = "org.http4k:http4k-testing-hamkrest:3.268.0"
const val junit = "org.junit.jupiter:junit-jupiter:5.6.2"
const val mockk = "io.mockk:mockk:1.10.0"
const val faker = "com.github.javafaker:javafaker:1.0.2"
const val mariaTestContainer = "org.testcontainers:mariadb:1.15.0-rc2"
}

View File

@ -6,6 +6,7 @@ plugins {
id("be.simplenotes.app-shadow")
id("be.simplenotes.app-css")
id("be.simplenotes.app-docker")
kotlin("kapt")
}
dependencies {
@ -16,7 +17,6 @@ dependencies {
implementation(project(":simplenotes-config"))
implementation(project(":simplenotes-views"))
implementation(Libs.koinCore)
implementation(Libs.arrowCoreData)
implementation(Libs.konform)
implementation(Libs.http4kCore)
@ -27,6 +27,12 @@ dependencies {
implementation(Libs.logbackClassic)
implementation(Libs.ktormCore)
implementation(Libs.micronaut)
kapt(Libs.micronautProcessor)
testImplementation(Libs.micronaut)
kaptTest(Libs.micronautProcessor)
testImplementation(Libs.junit)
testImplementation(Libs.assertJ)
testImplementation(Libs.http4kTestingHamkrest)

View File

@ -1,21 +1,27 @@
package be.simplenotes.app
import io.micronaut.context.annotation.Context
import org.http4k.server.Http4kServer
import org.slf4j.LoggerFactory
import javax.annotation.PostConstruct
import javax.annotation.PreDestroy
import be.simplenotes.config.ServerConfig as SimpleNotesServerConfig
@Context
class Server(
private val config: SimpleNotesServerConfig,
private val http4kServer: Http4kServer,
) {
private val logger = LoggerFactory.getLogger(javaClass)
@PostConstruct
fun start(): Server {
http4kServer.start()
logger.info("Listening on http://${config.host}:${config.port}")
return this
}
@PreDestroy
fun stop() {
logger.info("Stopping server")
http4kServer.close()

View File

@ -1,31 +1,12 @@
package be.simplenotes.app
import be.simplenotes.app.extensions.addShutdownHook
import be.simplenotes.app.modules.*
import be.simplenotes.config.configModule
import be.simplenotes.domain.domainModule
import be.simplenotes.persistance.migrationModule
import be.simplenotes.persistance.persistanceModule
import be.simplenotes.search.searchModule
import be.simplenotes.views.viewModule
import org.koin.core.context.startKoin
import org.koin.core.context.unloadKoinModules
import io.micronaut.context.ApplicationContext
fun main() {
startKoin {
modules(
serverModule,
persistanceModule,
migrationModule,
configModule,
viewModule,
controllerModule,
domainModule,
searchModule,
apiModule,
jsonModule
)
}.addShutdownHook()
unloadKoinModules(listOf(migrationModule, configModule))
val ctx = ApplicationContext.run().start()
Runtime.getRuntime().addShutdownHook(
Thread {
ctx.stop()
}
)
}

View File

@ -17,8 +17,13 @@ import org.http4k.core.Status.Companion.OK
import org.http4k.lens.Path
import org.http4k.lens.uuid
import java.util.*
import javax.inject.Singleton
class ApiNoteController(private val noteService: NoteService, private val json: Json) {
@Singleton
class ApiNoteController(
json: Json,
private val noteService: NoteService,
) {
fun createNote(request: Request, loggedInUser: LoggedInUser): Response {
val content = noteContentLens(request)

View File

@ -9,8 +9,13 @@ import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.BAD_REQUEST
import org.http4k.core.Status.Companion.OK
import javax.inject.Singleton
class ApiUserController(private val userService: UserService, private val json: Json) {
@Singleton
class ApiUserController(
json: Json,
private val userService: UserService,
) {
private val tokenLens = json.auto<Token>().toLens()
private val loginFormLens = json.auto<LoginForm>().toLens()

View File

@ -6,7 +6,9 @@ import be.simplenotes.views.BaseView
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.OK
import javax.inject.Singleton
@Singleton
class BaseController(private val view: BaseView) {
fun index(@Suppress("UNUSED_PARAMETER") request: Request, loggedInUser: LoggedInUser?) =
Response(OK).html(view.renderHome(loggedInUser))

View File

@ -5,7 +5,9 @@ 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
import javax.inject.Singleton
@Singleton
class HealthCheckController(private val dbHealthCheck: DbHealthCheck) {
fun healthCheck(@Suppress("UNUSED_PARAMETER") request: Request) =
if (dbHealthCheck.isOk()) Response(OK) else Response(SERVICE_UNAVAILABLE)

View File

@ -18,8 +18,10 @@ import org.http4k.core.Status.Companion.OK
import org.http4k.core.body.form
import org.http4k.routing.path
import java.util.*
import javax.inject.Singleton
import kotlin.math.abs
@Singleton
class NoteController(
private val view: NoteView,
private val noteService: NoteService,

View File

@ -10,7 +10,9 @@ import be.simplenotes.views.SettingView
import org.http4k.core.*
import org.http4k.core.body.form
import org.http4k.core.cookie.invalidateCookie
import javax.inject.Singleton
@Singleton
class SettingsController(
private val userService: UserService,
private val settingView: SettingView,

View File

@ -21,7 +21,9 @@ import org.http4k.core.cookie.SameSite
import org.http4k.core.cookie.cookie
import org.http4k.core.cookie.invalidateCookie
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Singleton
class UserController(
private val userService: UserService,
private val userView: UserView,

View File

@ -1,12 +0,0 @@
package be.simplenotes.app.extensions
import org.koin.core.KoinApplication
import kotlin.concurrent.thread
fun KoinApplication.addShutdownHook() {
Runtime.getRuntime().addShutdownHook(
thread(start = false) {
close()
}
)
}

View File

@ -1,58 +0,0 @@
package be.simplenotes.app.filters
import be.simplenotes.app.extensions.redirect
import be.simplenotes.domain.security.JwtPayloadExtractor
import be.simplenotes.types.LoggedInUser
import org.http4k.core.*
import org.http4k.core.Status.Companion.UNAUTHORIZED
import org.http4k.core.cookie.cookie
enum class AuthType {
Optional, Required
}
private const val authKey = "auth"
class AuthFilter(
private val extractor: JwtPayloadExtractor,
private val authType: AuthType,
private val ctx: RequestContexts,
private val source: JwtSource = JwtSource.Cookie,
private val redirect: Boolean = true,
) : Filter {
override fun invoke(next: HttpHandler): HttpHandler = {
val token = when (source) {
JwtSource.Header -> it.bearerTokenHeader()
JwtSource.Cookie -> it.bearerTokenCookie()
}
val jwtPayload = token?.let { extractor(token) }
when {
jwtPayload != null -> {
ctx[it][authKey] = jwtPayload
next(it)
}
authType == AuthType.Required -> {
if (redirect) Response.redirect("/login")
else Response(UNAUTHORIZED)
}
else -> next(it)
}
}
}
fun Request.jwtPayload(ctx: RequestContexts): LoggedInUser? = ctx[this][authKey]
enum class JwtSource {
Header, Cookie
}
private fun Request.bearerTokenCookie(): String? = cookie("Bearer")
?.value
?.trim()
private fun Request.bearerTokenHeader(): String? =
header("Authorization")
?.trim()
?.takeIf { it.startsWith("Bearer") }
?.substringAfter("Bearer")
?.trim()

View File

@ -10,7 +10,9 @@ import org.http4k.core.Status.Companion.NOT_IMPLEMENTED
import org.http4k.core.Status.Companion.SERVICE_UNAVAILABLE
import org.slf4j.LoggerFactory
import java.sql.SQLTransientException
import javax.inject.Singleton
@Singleton
class ErrorFilter(private val errorView: ErrorView) : Filter {
private val logger = LoggerFactory.getLogger(javaClass)

View File

@ -3,9 +3,13 @@ package be.simplenotes.app.filters
import org.http4k.core.Filter
import org.http4k.core.HttpHandler
import org.http4k.core.Request
import org.http4k.core.Status.Companion.OK
object ImmutableFilter : Filter {
override fun invoke(next: HttpHandler) = { request: Request ->
next(request).header("Cache-Control", "public, max-age=31536000, immutable")
val res = next(request)
if (res.status == OK)
res.header("Cache-Control", "public, max-age=31536000, immutable")
else res
}
}

View File

@ -3,7 +3,9 @@ package be.simplenotes.app.filters
import me.liuwj.ktorm.database.Database
import org.http4k.core.Filter
import org.http4k.core.HttpHandler
import javax.inject.Singleton
@Singleton
class TransactionFilter(private val db: Database) : Filter {
override fun invoke(next: HttpHandler): HttpHandler = { request ->
db.useTransaction {

View File

@ -0,0 +1,24 @@
package be.simplenotes.app.filters.auth
import be.simplenotes.types.LoggedInUser
import org.http4k.core.Request
import org.http4k.core.cookie.cookie
import org.http4k.lens.BiDiLens
typealias OptionalAuthLens = BiDiLens<@JvmSuppressWildcards Request, @JvmSuppressWildcards LoggedInUser?>
typealias RequiredAuthLens = BiDiLens<@JvmSuppressWildcards Request, @JvmSuppressWildcards LoggedInUser>
enum class JwtSource {
Header, Cookie
}
fun Request.bearerTokenCookie(): String? = cookie("Bearer")
?.value
?.trim()
fun Request.bearerTokenHeader(): String? =
header("Authorization")
?.trim()
?.takeIf { it.startsWith("Bearer") }
?.substringAfter("Bearer")
?.trim()

View File

@ -0,0 +1,22 @@
package be.simplenotes.app.filters.auth
import be.simplenotes.app.filters.auth.JwtSource.Cookie
import be.simplenotes.domain.security.JwtPayloadExtractor
import org.http4k.core.Filter
import org.http4k.core.HttpHandler
import org.http4k.core.with
class OptionalAuthFilter(
private val extractor: JwtPayloadExtractor,
private val lens: OptionalAuthLens,
private val source: JwtSource = Cookie,
) : Filter {
override fun invoke(next: HttpHandler): HttpHandler = {
val token = when (source) {
JwtSource.Header -> it.bearerTokenHeader()
Cookie -> it.bearerTokenCookie()
}
next(it.with(lens of token?.let { extractor(it) }))
}
}

View File

@ -0,0 +1,30 @@
package be.simplenotes.app.filters.auth
import be.simplenotes.app.extensions.redirect
import be.simplenotes.domain.security.JwtPayloadExtractor
import org.http4k.core.Filter
import org.http4k.core.HttpHandler
import org.http4k.core.Response
import org.http4k.core.Status.Companion.UNAUTHORIZED
import org.http4k.core.with
class RequiredAuthFilter(
private val extractor: JwtPayloadExtractor,
private val lens: RequiredAuthLens,
private val source: JwtSource = JwtSource.Cookie,
private val redirect: Boolean = true,
) : Filter {
override fun invoke(next: HttpHandler): HttpHandler = {
val token = when (source) {
JwtSource.Header -> it.bearerTokenHeader()
JwtSource.Cookie -> it.bearerTokenCookie()
}
val jwtPayload = token?.let { extractor(token) }
if (jwtPayload != null) next(it.with(lens of jwtPayload))
else {
if (redirect) Response.redirect("/login")
else Response(UNAUTHORIZED)
}
}
}

View File

@ -1,24 +0,0 @@
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
)
}
}

View File

@ -0,0 +1,46 @@
package be.simplenotes.app.modules
import be.simplenotes.app.filters.auth.*
import be.simplenotes.domain.security.JwtPayloadExtractor
import io.micronaut.context.annotation.Factory
import io.micronaut.context.annotation.Primary
import org.http4k.core.RequestContexts
import org.http4k.lens.RequestContextKey
import javax.inject.Named
import javax.inject.Singleton
@Factory
class AuthModule {
@Singleton
@Named("optional")
fun optionalAuthLens(ctx: RequestContexts): OptionalAuthLens = RequestContextKey.optional(ctx)
@Singleton
@Named("required")
fun requiredAuthLens(ctx: RequestContexts): RequiredAuthLens = RequestContextKey.required(ctx)
@Singleton
fun optionalAuth(extractor: JwtPayloadExtractor, @Named("optional") lens: OptionalAuthLens) =
OptionalAuthFilter(extractor, lens)
@Primary
@Singleton
fun requiredAuth(extractor: JwtPayloadExtractor, @Named("required") lens: RequiredAuthLens) =
RequiredAuthFilter(extractor, lens)
@Singleton
@Named("api")
internal fun apiAuthFilter(
jwtPayloadExtractor: JwtPayloadExtractor,
@Named("required") lens: RequiredAuthLens,
) = RequiredAuthFilter(
extractor = jwtPayloadExtractor,
lens = lens,
source = JwtSource.Header,
redirect = false
)
@Singleton
fun requestContexts() = RequestContexts()
}

View File

@ -1,12 +0,0 @@
package be.simplenotes.app.modules
import be.simplenotes.app.controllers.*
import org.koin.dsl.module
val controllerModule = module {
single { UserController(get(), get(), get()) }
single { HealthCheckController(get()) }
single { BaseController(get()) }
single { NoteController(get(), get()) }
single { SettingsController(get(), get()) }
}

View File

@ -2,21 +2,20 @@ package be.simplenotes.app.modules
import be.simplenotes.app.serialization.LocalDateTimeSerializer
import be.simplenotes.app.serialization.UuidSerializer
import io.micronaut.context.annotation.Factory
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import org.koin.dsl.module
import java.time.LocalDateTime
import java.util.*
import javax.inject.Singleton
val jsonModule = module {
single {
Json {
prettyPrint = true
serializersModule = get()
}
}
single {
SerializersModule {
@Factory
class JsonModule {
@Singleton
fun json() = Json {
prettyPrint = true
serializersModule = SerializersModule {
contextual(LocalDateTime::class, LocalDateTimeSerializer())
contextual(UUID::class, UuidSerializer())
}

View File

@ -1,62 +1,38 @@
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.jetty.ConnectorBuilder
import be.simplenotes.app.jetty.Jetty
import be.simplenotes.app.routes.Router
import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.app.utils.StaticFileResolverImpl
import be.simplenotes.config.ServerConfig
import io.micronaut.context.annotation.Factory
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.Http4kServer
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 javax.inject.Named
import javax.inject.Singleton
import org.eclipse.jetty.server.Server as JettyServer
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 ->
@Factory
class ServerModule {
@Singleton
@Named("styles")
fun styles(resolver: StaticFileResolver) = resolver.resolve("styles.css")!!
@Singleton
fun http4kServer(router: Router, serverConfig: Http4kServerConfig): Http4kServer =
router().asServer(serverConfig)
@Singleton
fun http4kServerConfig(config: ServerConfig): Http4kServerConfig {
val builder: ConnectorBuilder = { server: JettyServer ->
ServerConnector(server).apply {
port = config.port
host = config.host
}
}
Jetty(config.port, builder)
return 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(named("styles")) { get<StaticFileResolver>().resolve("styles.css") }
}

View File

@ -0,0 +1,54 @@
package be.simplenotes.app.routes
import be.simplenotes.app.api.ApiNoteController
import be.simplenotes.app.api.ApiUserController
import be.simplenotes.app.filters.TransactionFilter
import be.simplenotes.app.filters.auth.RequiredAuthFilter
import be.simplenotes.app.filters.auth.RequiredAuthLens
import org.http4k.core.Filter
import org.http4k.core.Method.*
import org.http4k.core.Request
import org.http4k.core.then
import org.http4k.routing.PathMethod
import org.http4k.routing.RoutingHttpHandler
import org.http4k.routing.bind
import org.http4k.routing.routes
import java.util.function.Supplier
import javax.inject.Named
import javax.inject.Singleton
@Singleton
class ApiRoutes(
private val apiUserController: ApiUserController,
private val apiNoteController: ApiNoteController,
private val transaction: TransactionFilter,
@Named("api") private val auth: RequiredAuthFilter,
@Named("required") private val authLens: RequiredAuthLens,
) : Supplier<RoutingHttpHandler> {
override fun get(): RoutingHttpHandler {
fun Filter.then(next: ProtectedHandler) = then { req: Request ->
next(req, authLens(req))
}
infix fun PathMethod.to(action: ProtectedHandler) =
this to { req: Request -> action(req, authLens(req)) }
return routes(
"/login" bind POST to apiUserController::login,
with(apiNoteController) {
auth.then(
routes(
"/" bind GET to ::notes,
"/" bind POST to transaction.then(::createNote),
"/search" bind POST to ::search,
"/{uuid}" bind GET to ::note,
"/{uuid}" bind PUT to transaction.then(::note),
)
).withBasePath("/notes")
}
).withBasePath("/api")
}
}

View File

@ -0,0 +1,67 @@
package be.simplenotes.app.routes
import be.simplenotes.app.controllers.BaseController
import be.simplenotes.app.controllers.HealthCheckController
import be.simplenotes.app.controllers.NoteController
import be.simplenotes.app.controllers.UserController
import be.simplenotes.app.filters.ImmutableFilter
import be.simplenotes.app.filters.TransactionFilter
import be.simplenotes.app.filters.auth.OptionalAuthFilter
import be.simplenotes.app.filters.auth.OptionalAuthLens
import org.http4k.core.ContentType
import org.http4k.core.Filter
import org.http4k.core.Method.GET
import org.http4k.core.Method.POST
import org.http4k.core.Request
import org.http4k.core.then
import org.http4k.routing.*
import java.util.function.Supplier
import javax.inject.Named
import javax.inject.Singleton
@Singleton
class BasicRoutes(
private val healthCheckController: HealthCheckController,
private val baseCtrl: BaseController,
private val userCtrl: UserController,
private val noteCtrl: NoteController,
@Named("optional") private val authLens: OptionalAuthLens,
private val auth: OptionalAuthFilter,
private val transactionFilter: TransactionFilter,
) : Supplier<RoutingHttpHandler> {
override fun get(): RoutingHttpHandler {
fun Filter.then(next: PublicHandler) = then { req: Request ->
next(req, authLens(req))
}
infix fun PathMethod.to(action: PublicHandler) =
this to { req: Request -> action(req, authLens(req)) }
val staticHandler = ImmutableFilter.then(
static(
ResourceLoader.Classpath("/static"),
"woff2" to ContentType("font/woff2"),
"webmanifest" to ContentType("application/manifest+json")
)
)
return routes(
auth.then(
routes(
"/" bind GET to baseCtrl::index,
"/register" bind GET to userCtrl::register,
"/register" bind POST to transactionFilter.then(userCtrl::register),
"/login" bind GET to userCtrl::login,
"/login" bind POST to userCtrl::login,
"/logout" bind POST to userCtrl::logout,
"/notes/public/{uuid}" bind GET to noteCtrl::public,
)
),
"/health" bind GET to healthCheckController::healthCheck,
staticHandler
)
}
}

View File

@ -0,0 +1,53 @@
package be.simplenotes.app.routes
import be.simplenotes.app.controllers.NoteController
import be.simplenotes.app.filters.TransactionFilter
import be.simplenotes.app.filters.auth.RequiredAuthFilter
import be.simplenotes.app.filters.auth.RequiredAuthLens
import org.http4k.core.Filter
import org.http4k.core.Method.GET
import org.http4k.core.Method.POST
import org.http4k.core.Request
import org.http4k.core.then
import org.http4k.routing.PathMethod
import org.http4k.routing.RoutingHttpHandler
import org.http4k.routing.bind
import org.http4k.routing.routes
import java.util.function.Supplier
import javax.inject.Named
import javax.inject.Singleton
@Singleton
class NoteRoutes(
private val noteCtrl: NoteController,
private val transaction: TransactionFilter,
private val auth: RequiredAuthFilter,
@Named("required") private val authLens: RequiredAuthLens,
) : Supplier<RoutingHttpHandler> {
override fun get(): RoutingHttpHandler {
fun Filter.then(next: ProtectedHandler) = then { req: Request ->
next(req, authLens(req))
}
infix fun PathMethod.to(action: ProtectedHandler) =
this to { req: Request -> action(req, authLens(req)) }
return auth.then(
with(noteCtrl) {
routes(
"/" bind GET to ::list,
"/" bind POST to ::search,
"/new" bind GET to ::new,
"/new" bind POST to transaction.then(::new),
"/trash" bind GET to ::trash,
"/{uuid}" bind GET to ::note,
"/{uuid}" bind POST to transaction.then(::note),
"/{uuid}/edit" bind GET to ::edit,
"/{uuid}/edit" bind POST to transaction.then(::edit),
"/deleted/{uuid}" bind POST to transaction.then(::deleted),
).withBasePath("/notes")
}
)
}
}

View File

@ -0,0 +1,8 @@
package be.simplenotes.app.routes
import be.simplenotes.types.LoggedInUser
import org.http4k.core.Request
import org.http4k.core.Response
internal typealias PublicHandler = (Request, LoggedInUser?) -> Response
internal typealias ProtectedHandler = (Request, LoggedInUser) -> Response

View File

@ -1,106 +1,32 @@
package be.simplenotes.app.routes
import be.simplenotes.app.api.ApiNoteController
import be.simplenotes.app.api.ApiUserController
import be.simplenotes.app.controllers.*
import be.simplenotes.app.filters.*
import be.simplenotes.types.LoggedInUser
import org.http4k.core.*
import org.http4k.core.Method.*
import be.simplenotes.app.filters.ErrorFilter
import be.simplenotes.app.filters.SecurityFilter
import org.http4k.core.RequestContexts
import org.http4k.core.then
import org.http4k.filter.ResponseFilters.GZip
import org.http4k.filter.ServerFilters.InitialiseRequestContext
import org.http4k.routing.*
import org.http4k.routing.ResourceLoader.Companion.Classpath
import org.http4k.routing.RoutingHttpHandler
import org.http4k.routing.routes
import java.util.function.Supplier
import javax.inject.Singleton
@Singleton
class Router(
private val baseController: BaseController,
private val userController: UserController,
private val noteController: NoteController,
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 apiAuth: Filter,
private val errorFilter: ErrorFilter,
private val transactionFilter: TransactionFilter,
private val contexts: RequestContexts,
private val subRouters: List<Supplier<RoutingHttpHandler>>,
) {
operator fun invoke(): RoutingHttpHandler {
val basicRoutes =
routes(
"/health" bind GET to healthCheckController::healthCheck,
ImmutableFilter.then(static(Classpath("/static"), "woff2" to ContentType("font/woff2")))
)
val publicRoutes = routes(
"/" bind GET public baseController::index,
"/register" bind GET 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,
"/notes/public/{uuid}" bind GET public noteController::public,
)
val protectedRoutes = routes(
"/settings" bind GET 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 transactional noteController::new,
"/notes/trash" bind GET protected noteController::trash,
"/notes/{uuid}" bind GET protected noteController::note,
"/notes/{uuid}" bind POST transactional noteController::note,
"/notes/{uuid}/edit" bind GET protected noteController::edit,
"/notes/{uuid}/edit" bind POST transactional noteController::edit,
"/notes/deleted/{uuid}" bind POST transactional noteController::deleted,
)
val apiRoutes = routes(
"/api/login" bind POST to apiUserController::login,
)
val protectedApiRoutes = routes(
"/api/notes" bind GET protected apiNoteController::notes,
"/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 transactional apiNoteController::update,
)
val routes = routes(
basicRoutes,
optionalAuth.then(publicRoutes),
requiredAuth.then(protectedRoutes),
apiAuth.then(protectedApiRoutes),
apiRoutes,
*subRouters.map { it.get() }.toTypedArray()
)
val globalFilters = errorFilter
return errorFilter
.then(InitialiseRequestContext(contexts))
.then(SecurityFilter)
.then(GZip())
return globalFilters.then(routes)
.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, LoggedInUser?) -> Response
private typealias ProtectedHandler = (Request, LoggedInUser) -> Response

View File

@ -0,0 +1,44 @@
package be.simplenotes.app.routes
import be.simplenotes.app.controllers.SettingsController
import be.simplenotes.app.filters.TransactionFilter
import be.simplenotes.app.filters.auth.RequiredAuthFilter
import be.simplenotes.app.filters.auth.RequiredAuthLens
import org.http4k.core.Filter
import org.http4k.core.Method.GET
import org.http4k.core.Method.POST
import org.http4k.core.Request
import org.http4k.core.then
import org.http4k.routing.PathMethod
import org.http4k.routing.RoutingHttpHandler
import org.http4k.routing.bind
import org.http4k.routing.routes
import java.util.function.Supplier
import javax.inject.Named
import javax.inject.Singleton
@Singleton
class SettingsRoutes(
private val settingsController: SettingsController,
private val transaction: TransactionFilter,
private val auth: RequiredAuthFilter,
@Named("required") private val authLens: RequiredAuthLens,
) : Supplier<RoutingHttpHandler> {
override fun get(): RoutingHttpHandler {
fun Filter.then(next: ProtectedHandler) = then { req: Request ->
next(req, authLens(req))
}
infix fun PathMethod.to(action: ProtectedHandler) =
this to { req: Request -> action(req, authLens(req)) }
return auth.then(
routes(
"/settings" bind GET to settingsController::settings,
"/settings" bind POST to transaction.then(settingsController::settings),
"/export" bind POST to settingsController::export,
)
)
}
}

View File

@ -3,11 +3,13 @@ package be.simplenotes.app.utils
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import javax.inject.Singleton
interface StaticFileResolver {
fun resolve(name: String): String?
}
@Singleton
class StaticFileResolverImpl(json: Json) : StaticFileResolver {
private val mappings: Map<String, String>

View File

@ -13,4 +13,5 @@
<logger name="me.liuwj.ktorm.database" level="INFO"/>
<logger name="com.zaxxer.hikari" level="INFO"/>
<logger name="org.flywaydb.core" level="INFO"/>
<logger name="io.micronaut" level="INFO"/>
</configuration>

View File

@ -1,15 +1,23 @@
package be.simplenotes.app.filters
import be.simplenotes.app.filters.auth.OptionalAuthFilter
import be.simplenotes.app.filters.auth.OptionalAuthLens
import be.simplenotes.app.filters.auth.RequiredAuthFilter
import be.simplenotes.app.filters.auth.RequiredAuthLens
import be.simplenotes.config.JwtConfig
import be.simplenotes.domain.security.JwtPayloadExtractor
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.types.LoggedInUser
import com.natpryce.hamkrest.assertion.assertThat
import org.http4k.core.*
import io.micronaut.context.BeanContext
import io.micronaut.inject.qualifiers.Qualifiers
import org.http4k.core.Method.GET
import org.http4k.core.Request
import org.http4k.core.RequestContexts
import org.http4k.core.Response
import org.http4k.core.Status.Companion.FOUND
import org.http4k.core.Status.Companion.OK
import org.http4k.core.cookie.cookie
import org.http4k.core.then
import org.http4k.filter.ServerFilters
import org.http4k.hamkrest.hasBody
import org.http4k.hamkrest.hasHeader
@ -20,22 +28,36 @@ import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.util.concurrent.TimeUnit
internal class AuthFilterTest {
internal class RequiredAuthFilterTest {
// region setup
private val jwtConfig = JwtConfig("secret", 1, TimeUnit.HOURS)
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 echoJwtPayloadHandler = { request: Request -> Response(OK).body(request.jwtPayload(ctx).toString()) }
private val beanCtx = BeanContext.build()
.registerSingleton(jwtConfig)
.start()
private inline fun <reified T> BeanContext.getBean(): T = getBean(T::class.java)
private inline fun <reified T> BeanContext.getBean(name: String): T =
getBean(T::class.java, Qualifiers.byName(name))
private val requiredAuth = beanCtx.getBean<RequiredAuthFilter>()
private val requiredLens = beanCtx.getBean<RequiredAuthLens>("required")
private val optionalAuth = beanCtx.getBean<OptionalAuthFilter>()
private val optionalLens = beanCtx.getBean<OptionalAuthLens>("optional")
private val ctx = beanCtx.getBean<RequestContexts>()
private val app = ServerFilters.InitialiseRequestContext(ctx).then(
routes(
"/optional" bind GET to optionalAuth.then(echoJwtPayloadHandler),
"/protected" bind GET to requiredAuth.then(echoJwtPayloadHandler)
"/optional" bind GET to optionalAuth.then { request: Request ->
Response(OK).body(optionalLens(request).toString())
},
"/protected" bind GET to requiredAuth.then { request: Request ->
Response(OK).body(requiredLens(request).toString())
}
)
)
// endregion

View File

@ -2,8 +2,10 @@ import be.simplenotes.Libs
plugins {
id("be.simplenotes.base")
kotlin("kapt")
}
dependencies {
implementation(Libs.koinCore)
implementation(Libs.micronaut)
kapt(Libs.micronautProcessor)
}

View File

@ -2,7 +2,9 @@ package be.simplenotes.config
import java.util.*
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Singleton
class ConfigLoader {
//region Config loading
private val properties: Properties = javaClass

View File

@ -1,10 +1,17 @@
package be.simplenotes.config
import org.koin.dsl.module
import io.micronaut.context.annotation.Factory
import javax.inject.Singleton
val configModule = module {
single { ConfigLoader() }
single { get<ConfigLoader>().dataSourceConfig }
single { get<ConfigLoader>().jwtConfig }
single { get<ConfigLoader>().serverConfig }
@Factory
class ConfigModule {
@Singleton
internal fun dataSourceConfig(configLoader: ConfigLoader) = configLoader.dataSourceConfig
@Singleton
internal fun jwtConfig(configLoader: ConfigLoader) = configLoader.jwtConfig
@Singleton
internal fun serverConfig(configLoader: ConfigLoader) = configLoader.serverConfig
}

View File

@ -3,6 +3,7 @@ import be.simplenotes.Libs
plugins {
id("be.simplenotes.base")
id("be.simplenotes.kotlinx-serialization")
kotlin("kapt")
}
dependencies {
@ -11,8 +12,10 @@ dependencies {
implementation(project(":simplenotes-persistance"))
implementation(project(":simplenotes-search"))
implementation(Libs.micronaut)
kapt(Libs.micronautProcessor)
implementation(Libs.kotlinxSerializationJson)
implementation(Libs.koinCore)
implementation(Libs.arrowCoreData)
implementation(Libs.konform)
implementation(Libs.jbcrypt)

View File

@ -1,37 +0,0 @@
package be.simplenotes.domain
import be.simplenotes.domain.security.BcryptPasswordHash
import be.simplenotes.domain.security.JwtPayloadExtractor
import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.domain.usecases.NoteService
import be.simplenotes.domain.usecases.UserService
import be.simplenotes.domain.usecases.export.ExportUseCase
import be.simplenotes.domain.usecases.export.ExportUseCaseImpl
import be.simplenotes.domain.usecases.markdown.MarkdownConverter
import be.simplenotes.domain.usecases.markdown.MarkdownConverterImpl
import be.simplenotes.domain.usecases.users.delete.DeleteUseCase
import be.simplenotes.domain.usecases.users.delete.DeleteUseCaseImpl
import be.simplenotes.domain.usecases.users.login.LoginUseCase
import be.simplenotes.domain.usecases.users.login.LoginUseCaseImpl
import be.simplenotes.domain.usecases.users.register.RegisterUseCase
import be.simplenotes.domain.usecases.users.register.RegisterUseCaseImpl
import org.koin.dsl.module
val domainModule = module {
single<LoginUseCase> { LoginUseCaseImpl(get(), get(), get()) }
single<RegisterUseCase> { RegisterUseCaseImpl(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()).apply {
dropAllIndexes()
indexAll()
}
}
single<MarkdownConverter> { MarkdownConverterImpl() }
single<ExportUseCase> { ExportUseCaseImpl(get(), get()) }
}

View File

@ -2,7 +2,9 @@ package be.simplenotes.domain.security
import be.simplenotes.types.LoggedInUser
import com.auth0.jwt.exceptions.JWTVerificationException
import javax.inject.Singleton
@Singleton
class JwtPayloadExtractor(private val jwt: SimpleJwt) {
operator fun invoke(token: String): LoggedInUser? = try {
val decodedJWT = jwt.verifier.verify(token)

View File

@ -1,13 +1,19 @@
package be.simplenotes.domain.security
import org.mindrot.jbcrypt.BCrypt
import javax.inject.Inject
import javax.inject.Singleton
internal interface PasswordHash {
fun crypt(password: String): String
fun verify(password: String, hashedPassword: String): Boolean
}
internal class BcryptPasswordHash(test: Boolean = false) : PasswordHash {
@Singleton
internal class BcryptPasswordHash constructor(test: Boolean) : PasswordHash {
@Inject
constructor() : this(false)
private val rounds = if (test) 4 else 10
override fun crypt(password: String) = BCrypt.hashpw(password, BCrypt.gensalt(rounds))!!
override fun verify(password: String, hashedPassword: String) = BCrypt.checkpw(password, hashedPassword)

View File

@ -7,10 +7,12 @@ import com.auth0.jwt.JWTVerifier
import com.auth0.jwt.algorithms.Algorithm
import java.util.*
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
internal const val userIdField = "i"
internal const val usernameField = "u"
@Singleton
class SimpleJwt(jwtConfig: JwtConfig) {
private val validityInMs = TimeUnit.MILLISECONDS.convert(jwtConfig.validity, jwtConfig.timeUnit)
private val algorithm = Algorithm.HMAC256(jwtConfig.secret)

View File

@ -12,7 +12,9 @@ import be.simplenotes.types.Note
import be.simplenotes.types.PersistedNote
import be.simplenotes.types.PersistedNoteMetadata
import java.util.*
import javax.inject.Singleton
@Singleton
class NoteService(
private val markdownConverter: MarkdownConverter,
private val noteRepository: NoteRepository,

View File

@ -4,7 +4,9 @@ import be.simplenotes.domain.usecases.export.ExportUseCase
import be.simplenotes.domain.usecases.users.delete.DeleteUseCase
import be.simplenotes.domain.usecases.users.login.LoginUseCase
import be.simplenotes.domain.usecases.users.register.RegisterUseCase
import javax.inject.Singleton
@Singleton
class UserService(
loginUseCase: LoginUseCase,
registerUseCase: RegisterUseCase,

View File

@ -2,6 +2,7 @@ package be.simplenotes.domain.usecases.export
import be.simplenotes.persistance.repositories.NoteRepository
import be.simplenotes.types.ExportedNote
import io.micronaut.context.annotation.Primary
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
@ -9,8 +10,14 @@ import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import javax.inject.Singleton
internal class ExportUseCaseImpl(private val noteRepository: NoteRepository, private val json: Json) : ExportUseCase {
@Primary
@Singleton
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)

View File

@ -14,6 +14,7 @@ import io.konform.validation.ValidationErrors
import org.yaml.snakeyaml.Yaml
import org.yaml.snakeyaml.parser.ParserException
import org.yaml.snakeyaml.scanner.ScannerException
import javax.inject.Singleton
sealed class MarkdownParsingError
object MissingMeta : MarkdownParsingError()
@ -28,6 +29,7 @@ interface MarkdownConverter {
fun renderDocument(input: String): Either<MarkdownParsingError, Document>
}
@Singleton
internal class MarkdownConverterImpl : MarkdownConverter {
private val yamlBoundPattern = "-{3}".toRegex()
private fun splitMetaFromDocument(input: String): Either<MissingMeta, MetaMdPair> {

View File

@ -7,7 +7,11 @@ import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.validation.UserValidations
import be.simplenotes.persistance.repositories.UserRepository
import be.simplenotes.search.NoteSearcher
import io.micronaut.context.annotation.Primary
import javax.inject.Singleton
@Primary
@Singleton
internal class DeleteUseCaseImpl(
private val userRepository: UserRepository,
private val passwordHash: PasswordHash,

View File

@ -8,7 +8,11 @@ import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.domain.validation.UserValidations
import be.simplenotes.persistance.repositories.UserRepository
import be.simplenotes.types.LoggedInUser
import io.micronaut.context.annotation.Primary
import javax.inject.Singleton
@Singleton
@Primary
internal class LoginUseCaseImpl(
private val userRepository: UserRepository,
private val passwordHash: PasswordHash,

View File

@ -7,7 +7,11 @@ import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.validation.UserValidations
import be.simplenotes.persistance.repositories.UserRepository
import be.simplenotes.types.PersistedUser
import io.micronaut.context.annotation.Primary
import javax.inject.Singleton
@Primary
@Singleton
internal class RegisterUseCaseImpl(
private val userRepository: UserRepository,
private val passwordHash: PasswordHash

View File

@ -3,14 +3,13 @@ import be.simplenotes.Libs
plugins {
id("be.simplenotes.base")
kotlin("kapt")
`java-test-fixtures`
}
dependencies {
implementation(project(":simplenotes-types"))
implementation(project(":simplenotes-config"))
implementation(Libs.mapstruct)
implementation(Libs.koinCore)
implementation(Libs.mariadbClient)
implementation(Libs.h2)
implementation(Libs.flywayCore)
@ -18,8 +17,36 @@ dependencies {
implementation(Libs.ktormCore)
implementation(Libs.ktormMysql)
implementation(Libs.mapstruct)
kapt(Libs.mapstructProcessor)
implementation(Libs.micronaut)
kapt(Libs.micronautProcessor)
testImplementation(Libs.micronaut)
kaptTest(Libs.micronautProcessor)
testImplementation(Libs.junit)
testImplementation(Libs.assertJ)
testImplementation(Libs.logbackClassic)
testImplementation(Libs.mariaTestContainer)
testFixturesImplementation(project(":simplenotes-types"))
testFixturesImplementation(project(":simplenotes-config"))
testFixturesImplementation(project(":simplenotes-persistance"))
testFixturesImplementation(Libs.micronaut)
kaptTestFixtures(Libs.micronautProcessor)
testFixturesImplementation(Libs.faker) {
exclude(group = "org.yaml")
}
testFixturesImplementation(Libs.snakeyaml)
testFixturesImplementation(Libs.mariaTestContainer)
testFixturesImplementation(Libs.flywayCore)
testFixturesImplementation(Libs.junit)
testFixturesImplementation(Libs.ktormCore)
testFixturesImplementation(Libs.hikariCP)
}

View File

@ -7,11 +7,13 @@ import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.database.asIterable
import me.liuwj.ktorm.database.use
import java.sql.SQLTransientException
import javax.inject.Singleton
interface DbHealthCheck {
fun isOk(): Boolean
}
@Singleton
internal class DbHealthCheckImpl(
private val db: Database,
private val dataSourceConfig: DataSourceConfig,

View File

@ -4,12 +4,14 @@ import be.simplenotes.config.DataSourceConfig
import be.simplenotes.persistance.utils.DbType
import be.simplenotes.persistance.utils.type
import org.flywaydb.core.Flyway
import javax.inject.Singleton
import javax.sql.DataSource
interface DbMigrations {
fun migrate()
}
@Singleton
internal class DbMigrationsImpl(
private val dataSource: DataSource,
private val dataSourceConfig: DataSourceConfig,

View File

@ -2,46 +2,42 @@ package be.simplenotes.persistance
import be.simplenotes.config.DataSourceConfig
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.repositories.NoteRepository
import be.simplenotes.persistance.repositories.UserRepository
import be.simplenotes.persistance.users.UserRepositoryImpl
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import io.micronaut.context.annotation.Bean
import io.micronaut.context.annotation.Factory
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.inject.Singleton
import javax.sql.DataSource
private fun hikariDataSource(conf: DataSourceConfig): HikariDataSource {
val hikariConfig = HikariConfig().also {
it.jdbcUrl = conf.jdbcUrl
it.driverClassName = conf.driverClassName
it.username = conf.username
it.password = conf.password
it.maximumPoolSize = conf.maximumPoolSize
it.connectionTimeout = conf.connectionTimeout
}
return HikariDataSource(hikariConfig)
}
@Factory
class PersistanceModule {
val migrationModule = module {
single<DbMigrations> { DbMigrationsImpl(get(), get()) }
}
@Singleton
internal fun noteConverter() = Mappers.getMapper(NoteConverter::class.java)
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>())
@Singleton
internal fun userConverter() = Mappers.getMapper(UserConverter::class.java)
@Singleton
internal fun database(migrations: DbMigrations, dataSource: DataSource): Database {
migrations.migrate()
return Database.connect(dataSource)
}
@Singleton
@Bean(preDestroy = "close")
internal fun dataSource(conf: DataSourceConfig): HikariDataSource {
val hikariConfig = HikariConfig().also {
it.jdbcUrl = conf.jdbcUrl
it.driverClassName = conf.driverClassName
it.username = conf.username
it.password = conf.password
it.maximumPoolSize = conf.maximumPoolSize
it.connectionTimeout = conf.connectionTimeout
}
return HikariDataSource(hikariConfig)
}
single<DbHealthCheck> { DbHealthCheckImpl(get(), get()) }
}

View File

@ -11,9 +11,14 @@ import me.liuwj.ktorm.dsl.*
import me.liuwj.ktorm.entity.*
import java.time.LocalDateTime
import java.util.*
import javax.inject.Singleton
import kotlin.collections.HashMap
internal class NoteRepositoryImpl(private val db: Database, private val converter: NoteConverter) : NoteRepository {
@Singleton
internal class NoteRepositoryImpl(
private val db: Database,
private val converter: NoteConverter,
) : NoteRepository {
@Throws(IllegalArgumentException::class)
override fun findAll(

View File

@ -9,8 +9,13 @@ import me.liuwj.ktorm.dsl.*
import me.liuwj.ktorm.entity.any
import me.liuwj.ktorm.entity.find
import java.sql.SQLIntegrityConstraintViolationException
import javax.inject.Singleton
internal class UserRepositoryImpl(private val db: Database, private val converter: UserConverter) : UserRepository {
@Singleton
internal class UserRepositoryImpl(
private val db: Database,
private val converter: UserConverter,
) : UserRepository {
override fun create(user: User): PersistedUser? {
return try {
val id = db.insertAndGenerateKey(Users) {

View File

@ -0,0 +1,24 @@
package be.simplenotes.persistance
import be.simplenotes.config.DataSourceConfig
import org.testcontainers.containers.MariaDBContainer
class KMariadbContainer : MariaDBContainer<KMariadbContainer>("mariadb:10.5.5")
fun h2dataSourceConfig() = DataSourceConfig(
jdbcUrl = "jdbc:h2:mem:regular;DB_CLOSE_DELAY=-1;",
driverClassName = "org.h2.Driver",
username = "h2",
password = "",
maximumPoolSize = 2,
connectionTimeout = 3000
)
fun mariadbDataSourceConfig(jdbcUrl: String) = DataSourceConfig(
jdbcUrl = jdbcUrl,
driverClassName = "org.mariadb.jdbc.Driver",
username = "test",
password = "test",
maximumPoolSize = 2,
connectionTimeout = 3000
)

View File

@ -0,0 +1,35 @@
package be.simplenotes.persistance
import be.simplenotes.config.DataSourceConfig
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.parallel.ResourceLock
@ResourceLock("h2")
class H2DbHealthCheckImplTest : DbTest() {
override fun dataSourceConfig() = h2dataSourceConfig()
@Test
fun healthCheck() {
assertThat(beanContext.getBean<DbHealthCheck>().isOk()).isTrue
}
}
@ResourceLock("mariadb")
class MariaDbHealthCheckImplTest : DbTest() {
lateinit var mariaDB: KMariadbContainer
override fun dataSourceConfig(): DataSourceConfig {
mariaDB = KMariadbContainer()
mariaDB.start()
return mariadbDataSourceConfig(mariaDB.jdbcUrl)
}
@Test
fun healthCheck() {
val healthCheck = beanContext.getBean<DbHealthCheck>()
assertThat(healthCheck.isOk()).isTrue
mariaDB.stop()
assertThat(healthCheck.isOk()).isFalse
}
}

View File

@ -0,0 +1,44 @@
package be.simplenotes.persistance
import be.simplenotes.config.DataSourceConfig
import io.micronaut.context.BeanContext
import org.flywaydb.core.Flyway
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import javax.sql.DataSource
abstract class DbTest {
abstract fun dataSourceConfig(): DataSourceConfig
val beanContext = BeanContext
.build()
inline fun <reified T> BeanContext.getBean(): T = getBean(T::class.java)
@BeforeAll
fun setComponent() {
beanContext
.registerSingleton(dataSourceConfig())
.start()
}
@BeforeEach
fun beforeEach() {
val migration = beanContext.getBean<DbMigrations>()
val dataSource = beanContext.getBean<DataSource>()
Flyway.configure()
.dataSource(dataSource)
.load()
.clean()
migration.migrate()
}
@AfterAll
fun closeCtx() {
beanContext.close()
}
}

View File

@ -1,13 +1,12 @@
package be.simplenotes.persistance.notes
import be.simplenotes.config.DataSourceConfig
import be.simplenotes.persistance.DbMigrations
import be.simplenotes.persistance.DbTest
import be.simplenotes.persistance.converters.NoteConverter
import be.simplenotes.persistance.migrationModule
import be.simplenotes.persistance.persistanceModule
import be.simplenotes.persistance.repositories.NoteRepository
import be.simplenotes.persistance.repositories.UserRepository
import be.simplenotes.types.*
import be.simplenotes.persistance.users.createFakeUser
import be.simplenotes.types.ExportedNote
import be.simplenotes.types.PersistedUser
import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.dsl.eq
import me.liuwj.ktorm.entity.filter
@ -16,79 +15,35 @@ 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
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
@ResourceLock("h2")
internal class NoteRepositoryImplTest {
private val testModule = module {
single { dataSourceConfig() }
}
internal abstract class BaseNoteRepositoryImplTest : DbTest() {
private val koinApp = koinApplication {
modules(persistanceModule, migrationModule, testModule)
}
private fun dataSourceConfig() = DataSourceConfig(
jdbcUrl = "jdbc:h2:mem:regular;DB_CLOSE_DELAY=-1;",
driverClassName = "org.h2.Driver",
username = "h2",
password = "",
maximumPoolSize = 2,
connectionTimeout = 3000
)
private val koin = koinApp.koin
@AfterAll
fun afterAll() = koinApp.close()
private val migration = koin.get<DbMigrations>()
private val dataSource = koin.get<DataSource>()
private val noteRepo = koin.get<NoteRepository>()
private val userRepo = koin.get<UserRepository>()
private val db = koin.get<Database>()
private lateinit var noteRepo: NoteRepository
private lateinit var userRepo: UserRepository
private lateinit var db: Database
private lateinit var user1: PersistedUser
private lateinit var user2: PersistedUser
@BeforeEach
fun beforeEach() {
Flyway.configure()
.dataSource(dataSource)
.load()
.clean()
migration.migrate()
user1 = userRepo.create(User("1", "1"))!!
user2 = userRepo.create(User("2", "2"))!!
fun insertUsers() {
noteRepo = beanContext.getBean()
userRepo = beanContext.getBean()
db = beanContext.getBean()
user1 = userRepo.createFakeUser()!!
user2 = userRepo.createFakeUser()!!
}
private fun createNote(
userId: Int,
title: String,
tags: List<String> = emptyList(),
md: String = "md",
html: String = "html",
): PersistedNote = noteRepo.create(userId, Note(NoteMetadata(title, tags), md, html))
private fun PersistedNote.toPersistedMeta() = PersistedNoteMetadata(meta.title, meta.tags, updatedAt, uuid)
@Nested
@DisplayName("create()")
inner class Create {
@Test
fun `create note for non existing user`() {
val note = Note(NoteMetadata("title", emptyList()), "md", "html")
val note = fakeNote()
assertThatThrownBy {
noteRepo.create(1000, note)
@ -97,7 +52,7 @@ internal class NoteRepositoryImplTest {
@Test
fun `create note for existing user`() {
val note = Note(NoteMetadata("title", emptyList()), "md", "html")
val note = fakeNote()
assertThat(noteRepo.create(user1.id, note))
.isEqualToIgnoringGivenFields(note, "uuid", "updatedAt", "public")
@ -116,15 +71,8 @@ internal class NoteRepositoryImplTest {
@Test
fun `find all notes`() {
val notes1 = listOf(
createNote(user1.id, "1", listOf("a", "b")),
createNote(user1.id, "2"),
createNote(user1.id, "3", listOf("c"))
)
val notes2 = listOf(
createNote(user2.id, "4")
)
val notes1 = noteRepo.insertFakeNotes(user1, count = 3)
val notes2 = listOf(noteRepo.insertFakeNote(user2))
assertThat(noteRepo.findAll(user1.id))
.hasSize(3)
@ -143,13 +91,15 @@ internal class NoteRepositoryImplTest {
assertThat(noteRepo.findAll(1000)).isEmpty()
}
// TODO: datetime -> timestamp migration
@Disabled("Not working with mariadb, inserts are too fast so the updated_at is the same")
@Test
fun pagination() {
(50 downTo 1).forEach {
createNote(user1.id, "$it")
(50 downTo 1).forEach { i ->
noteRepo.insertFakeNote(user1, "$i")
}
assertThat(noteRepo.findAll(user1.id, limit = 20, offset = 0))
assertThat(noteRepo.findAll(user1.id, limit = 20, offset = 0).onEach { println(it) })
.hasSize(20)
.allMatch { it.title.toInt() in 1..20 }
@ -164,12 +114,13 @@ internal class NoteRepositoryImplTest {
@Test
fun `find all notes with tag`() {
createNote(user1.id, "1", listOf("a", "b"))
createNote(user1.id, "2")
createNote(user1.id, "3", listOf("c"))
createNote(user1.id, "4", listOf("c"))
createNote(user2.id, "5", listOf("c"))
with(noteRepo) {
insertFakeNote(user1, "1", listOf("a", "b"))
insertFakeNote(user1, "2", tags = emptyList())
insertFakeNote(user1, "3", listOf("c"))
insertFakeNote(user1, "4", listOf("c"))
insertFakeNote(user2, "5", listOf("c"))
}
assertThat(noteRepo.findAll(user1.id, tag = "a"))
.hasSize(1)
.first()
@ -189,34 +140,31 @@ internal class NoteRepositoryImplTest {
@Test
@Suppress("UNCHECKED_CAST")
fun `find an existing note`() {
createNote(user1.id, "1", listOf("a", "b"))
val fakeNote = noteRepo.insertFakeNote(user1)
val converter = Mappers.getMapper(NoteConverter::class.java)
val note = db.notes.find { it.title eq "1" }!!
val note = db.notes.find { it.title eq fakeNote.meta.title }!!
.let { entity ->
val tags = db.tags.filter { it.noteUuid eq entity.uuid }.mapColumns { it.name } as List<String>
converter.toPersistedNote(entity, tags)
}
assertThat(noteRepo.find(user1.id, note.uuid))
.isEqualTo(note)
assertThat(noteRepo.exists(user1.id, note.uuid))
.isTrue
assertThat(noteRepo.find(user1.id, note.uuid)).isEqualTo(note)
assertThat(noteRepo.exists(user1.id, note.uuid)).isTrue
}
@Test
fun `find an existing note from the wrong user`() {
val note = createNote(user1.id, "1", listOf("a", "b"))
val note = noteRepo.insertFakeNote(user1)
assertThat(noteRepo.find(user2.id, note.uuid)).isNull()
assertThat(noteRepo.exists(user2.id, note.uuid)).isFalse
}
@Test
fun `find a non existing note`() {
createNote(user1.id, "1", listOf("a", "b"))
val uuid = UUID.randomUUID()
noteRepo.insertFakeNote(user1)
val uuid = fakeUuid()
assertThat(noteRepo.find(user1.id, uuid)).isNull()
assertThat(noteRepo.exists(user2.id, uuid)).isFalse
}
@ -228,16 +176,14 @@ internal class NoteRepositoryImplTest {
@Test
fun `delete an existing note for a user should succeed and then fail`() {
val note = createNote(user1.id, "1", listOf("a", "b"))
assertThat(noteRepo.delete(user1.id, note.uuid))
.isTrue
val note = noteRepo.insertFakeNote(user1)
assertThat(noteRepo.delete(user1.id, note.uuid)).isTrue
}
@Test
fun `delete an existing note for the wrong user`() {
val note = createNote(user1.id, "1", listOf("a", "b"))
assertThat(noteRepo.delete(1000, note.uuid))
.isFalse
val note = noteRepo.insertFakeNote(user1)
assertThat(noteRepo.delete(1000, note.uuid)).isFalse
}
}
@ -247,15 +193,8 @@ internal class NoteRepositoryImplTest {
@Test
fun getTags() {
val notes1 = listOf(
createNote(user1.id, "1", listOf("a", "b")),
createNote(user1.id, "2"),
createNote(user1.id, "3", listOf("c", "a"))
)
val notes2 = listOf(
createNote(user2.id, "4", listOf("a"))
)
val notes1 = noteRepo.insertFakeNotes(user1, count = 3)
val notes2 = noteRepo.insertFakeNotes(user2, count = 1)
val user1Tags = notes1.flatMap { it.meta.tags }.toSet()
assertThat(noteRepo.getTags(user1.id))
@ -276,16 +215,18 @@ internal class NoteRepositoryImplTest {
@Test
fun getTags() {
val note1 = createNote(user1.id, "1", listOf("a", "b"))
val newNote1 = Note(meta = note1.meta, markdown = "new", "new")
assertThat(noteRepo.update(user1.id, note1.uuid, newNote1))
.isNotNull
val note1 = noteRepo.insertFakeNote(user1)
val newNote1 = fakeNote()
assertThat(noteRepo.update(user1.id, note1.uuid, newNote1)).isNotNull
assertThat(noteRepo.find(user1.id, note1.uuid))
.isEqualToComparingOnlyGivenFields(newNote1, "meta", "markdown", "html")
val note2 = createNote(user1.id, "2")
val newNote2 = Note(meta = note1.meta.copy(tags = listOf("a")), markdown = "new", "new")
val note2 = noteRepo.insertFakeNote(user1)
val newNote2 = fakeNote().let {
it.copy(meta = it.meta.copy(tags = tagGenerator().take(3).toList()))
}
assertThat(noteRepo.update(user1.id, note2.uuid, newNote2))
.isNotNull
@ -299,7 +240,7 @@ internal class NoteRepositoryImplTest {
@Test
fun `trashed noted should be restored`() {
val note1 = createNote(user1.id, "1", listOf("a", "b"))
val note1 = noteRepo.insertFakeNote(user1, "1", listOf("a", "b"))
assertThat(noteRepo.delete(user1.id, note1.uuid, permanent = false))
.isTrue
@ -321,11 +262,69 @@ internal class NoteRepositoryImplTest {
@Test
fun `permanent delete`() {
val note1 = createNote(user1.id, "1", listOf("a", "b"))
assertThat(noteRepo.delete(user1.id, note1.uuid, permanent = true))
.isTrue
assertThat(noteRepo.restore(user1.id, note1.uuid)).isFalse
val note = noteRepo.insertFakeNote(user1)
assertThat(noteRepo.delete(user1.id, note.uuid, permanent = true)).isTrue
assertThat(noteRepo.restore(user1.id, note.uuid)).isFalse
}
}
@Test
fun count() {
assertThat(noteRepo.count(user1.id)).isEqualTo(0)
noteRepo.insertFakeNotes(user1, count = 10)
assertThat(noteRepo.count(user1.id)).isEqualTo(10)
}
@Test
fun countWithTag() {
noteRepo.insertFakeNote(user1, tags = listOf("a", "b"))
noteRepo.insertFakeNote(user1, tags = emptyList())
noteRepo.insertFakeNote(user1, tags = listOf("a"))
noteRepo.insertFakeNote(user1, tags = emptyList())
assertThat(noteRepo.count(user1.id, tag = "a")).isEqualTo(2)
}
@Test
fun export() {
val notes = noteRepo.insertFakeNotes(user1, count = 4)
noteRepo.delete(user1.id, notes.first().uuid, permanent = false)
val export = noteRepo.export(user1.id)
val expected = notes.mapIndexed { i, n ->
ExportedNote(
title = n.meta.title,
tags = n.meta.tags,
markdown = n.markdown,
html = n.html,
updatedAt = n.updatedAt,
trash = i == 0,
)
}
assertThat(export)
.usingElementComparatorIgnoringFields("updatedAt")
.containsExactlyInAnyOrderElementsOf(expected)
}
@Test
fun findAllDetails() {
val notes = noteRepo.insertFakeNotes(user1, count = 10)
val res = noteRepo.findAllDetails(user1.id)
assertThat(res)
.usingElementComparatorIgnoringFields("updatedAt")
.containsExactlyInAnyOrderElementsOf(notes)
}
@Test
fun access() {
val n = noteRepo.insertFakeNote(user1)
noteRepo.makePublic(user1.id, n.uuid)
assertThat(noteRepo.findPublic(n.uuid))
.isEqualToIgnoringGivenFields(n.copy(public = true), "updatedAt")
noteRepo.makePrivate(user1.id, n.uuid)
assertThat(noteRepo.findPublic(n.uuid)).isNull()
}
}

View File

@ -0,0 +1,29 @@
package be.simplenotes.persistance.notes
import be.simplenotes.config.DataSourceConfig
import be.simplenotes.persistance.KMariadbContainer
import be.simplenotes.persistance.h2dataSourceConfig
import be.simplenotes.persistance.mariadbDataSourceConfig
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.parallel.ResourceLock
@ResourceLock("h2")
internal class H2NoteRepositoryImplTests : BaseNoteRepositoryImplTest() {
override fun dataSourceConfig() = h2dataSourceConfig()
}
@ResourceLock("mariadb")
internal class MariaDbNoteRepositoryImplTests : BaseNoteRepositoryImplTest() {
lateinit var mariaDB: KMariadbContainer
@AfterAll
fun stopMariaDB() {
mariaDB.stop()
}
override fun dataSourceConfig(): DataSourceConfig {
mariaDB = KMariadbContainer()
mariaDB.start()
return mariadbDataSourceConfig(mariaDB.jdbcUrl)
}
}

View File

@ -1,73 +1,34 @@
package be.simplenotes.persistance.users
import be.simplenotes.config.DataSourceConfig
import be.simplenotes.persistance.DbMigrations
import be.simplenotes.persistance.migrationModule
import be.simplenotes.persistance.persistanceModule
import be.simplenotes.persistance.DbTest
import be.simplenotes.persistance.repositories.UserRepository
import be.simplenotes.types.User
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.find
import me.liuwj.ktorm.entity.toList
import org.assertj.core.api.Assertions.assertThat
import org.flywaydb.core.Flyway
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.parallel.ResourceLock
import org.koin.dsl.koinApplication
import org.koin.dsl.module
import javax.sql.DataSource
@ResourceLock("h2")
internal class UserRepositoryImplTest {
internal abstract class BaseUserRepositoryImplTest : DbTest() {
// region setup
private val testModule = module {
single { dataSourceConfig() }
}
private val koinApp = koinApplication {
modules(persistanceModule, migrationModule, testModule)
}
private fun dataSourceConfig() = DataSourceConfig(
jdbcUrl = "jdbc:h2:mem:regular;DB_CLOSE_DELAY=-1;",
driverClassName = "org.h2.Driver",
username = "h2",
password = "",
maximumPoolSize = 2,
connectionTimeout = 3000
)
private val koin = koinApp.koin
@AfterAll
fun afterAll() = koinApp.close()
private val migration = koin.get<DbMigrations>()
private val dataSource = koin.get<DataSource>()
private val userRepo = koin.get<UserRepository>()
private val db = koin.get<Database>()
private lateinit var userRepo: UserRepository
private lateinit var db: Database
@BeforeEach
fun beforeEach() {
Flyway.configure()
.dataSource(dataSource)
.load()
.clean()
migration.migrate()
fun setup() {
userRepo = beanContext.getBean()
db = beanContext.getBean()
}
// endregion setup
@Test
fun `insert user`() {
val user = User("username", "test")
val user = fakeUser()
assertThat(userRepo.create(user))
.isNotNull
.hasFieldOrPropertyWithValue("username", user.username)
.hasFieldOrPropertyWithValue("password", user.password)
.extracting("username", "password")
.contains(user.username, user.password)
assertThat(db.users.find { it.username eq user.username }).isNotNull
assertThat(db.users.toList()).hasSize(1)
@ -79,7 +40,7 @@ internal class UserRepositoryImplTest {
@Test
fun `query existing user`() {
val user = User("username", "test")
val user = fakeUser()
userRepo.create(user)
val foundUserMaybe = userRepo.find(user.username)
@ -105,7 +66,7 @@ internal class UserRepositoryImplTest {
@Test
fun `delete existing user`() {
val user = User("username", "test")
val user = fakeUser()
userRepo.create(user)
val foundUser = userRepo.find(user.username)!!

View File

@ -0,0 +1,29 @@
package be.simplenotes.persistance.users
import be.simplenotes.config.DataSourceConfig
import be.simplenotes.persistance.KMariadbContainer
import be.simplenotes.persistance.h2dataSourceConfig
import be.simplenotes.persistance.mariadbDataSourceConfig
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.parallel.ResourceLock
@ResourceLock("h2")
internal class UserRepositoryImplTest : BaseUserRepositoryImplTest() {
override fun dataSourceConfig() = h2dataSourceConfig()
}
@ResourceLock("mariadb")
internal class MariaDbUserRepositoryImplTest : BaseUserRepositoryImplTest() {
lateinit var mariaDB: KMariadbContainer
@AfterAll
fun stopMariaDB() {
mariaDB.stop()
}
override fun dataSourceConfig(): DataSourceConfig {
mariaDB = KMariadbContainer()
mariaDB.start()
return mariadbDataSourceConfig(mariaDB.jdbcUrl)
}
}

View File

@ -0,0 +1,15 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<withJansi>true</withJansi>
<encoder>
<pattern>%cyan(%d{YYYY-MM-dd HH:mm:ss.SSS}) [%thread] %highlight(%-5level) %green(%logger{36}) - %msg%n
</pattern>
</encoder>
</appender>
<root level="DEBUG">
<appender-ref ref="STDOUT"/>
</root>
<logger name="me.liuwj.ktorm.database" level="DEBUG"/>
<logger name="com.zaxxer.hikari" level="INFO"/>
<logger name="org.flywaydb.core" level="DEBUG"/>
</configuration>

View File

@ -0,0 +1,35 @@
package be.simplenotes.persistance
import be.simplenotes.config.DataSourceConfig
import io.micronaut.context.BeanContext
import org.flywaydb.core.Flyway
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import javax.sql.DataSource
abstract class DbTest {
abstract fun dataSourceConfig(): DataSourceConfig
val beanContext = BeanContext.build()
inline fun <reified T> BeanContext.getBean(): T = getBean(T::class.java)
@BeforeAll
fun setComponent() {
beanContext.registerSingleton(dataSourceConfig())
}
@BeforeEach
fun beforeEach() {
val migration = beanContext.getBean<DbMigrations>()
val dataSource = beanContext.getBean<DataSource>()
Flyway.configure()
.dataSource(dataSource)
.load()
.clean()
migration.migrate()
}
}

View File

@ -0,0 +1,37 @@
package be.simplenotes.persistance.notes
import be.simplenotes.persistance.repositories.NoteRepository
import be.simplenotes.types.*
import com.github.javafaker.Faker
import java.util.*
private val faker = Faker()
fun fakeUuid() = UUID.randomUUID()!!
fun fakeTitle() = faker.lorem().characters(3, 50)!!
fun tagGenerator() = generateSequence { faker.lorem().word() }
fun fakeTags() = tagGenerator().take(faker.random().nextInt(0, 3)).toList()
fun fakeContent() = faker.lorem().paragraph(faker.random().nextInt(0, 3))!!
fun fakeNote() = Note(NoteMetadata(fakeTitle(), fakeTags()), fakeContent(), fakeContent())
fun PersistedNote.toPersistedMeta() =
PersistedNoteMetadata(meta.title, meta.tags, updatedAt, uuid)
fun NoteRepository.insertFakeNote(
userId: Int,
title: String,
tags: List<String> = emptyList(),
md: String = "md",
html: String = "html",
): PersistedNote = create(userId, Note(NoteMetadata(title, tags), md, html))
fun NoteRepository.insertFakeNote(
user: PersistedUser,
title: String = fakeTitle(),
tags: List<String> = fakeTags(),
md: String = fakeContent(),
html: String = fakeContent(),
): PersistedNote = insertFakeNote(user.id, title, tags, md, html)
fun NoteRepository.insertFakeNotes(user: PersistedUser, count: Int): List<PersistedNote> =
generateSequence { insertFakeNote(user) }.take(count).toList()

View File

@ -0,0 +1,14 @@
package be.simplenotes.persistance.users
import be.simplenotes.persistance.repositories.UserRepository
import be.simplenotes.types.PersistedUser
import be.simplenotes.types.User
import com.github.javafaker.Faker
private val faker = Faker()
fun fakeUser() = User(faker.name().username(), faker.internet().password())
fun UserRepository.createFakeUser(): PersistedUser? {
return create(fakeUser())
}

View File

@ -2,6 +2,7 @@ import be.simplenotes.Libs
plugins {
id("be.simplenotes.base")
kotlin("kapt")
}
dependencies {
@ -11,7 +12,9 @@ dependencies {
implementation(Libs.luceneQueryParser)
implementation(Libs.luceneAnalyzersCommon)
implementation(Libs.slf4jApi)
implementation(Libs.koinCore)
implementation(Libs.micronaut)
kapt(Libs.micronautProcessor)
testImplementation(Libs.junit)
testImplementation(Libs.assertJ)

View File

@ -12,8 +12,17 @@ import org.slf4j.LoggerFactory
import java.io.File
import java.nio.file.Path
import java.util.*
import javax.inject.Named
import javax.inject.Singleton
@Singleton
internal class NoteSearcherImpl(
@Named("search-index")
basePath: Path,
) : NoteSearcher {
constructor() : this(Path.of("/tmp", "lucene"))
internal class NoteSearcherImpl(basePath: Path = Path.of("/tmp", "lucene")) : NoteSearcher {
private val baseFile = basePath.toFile()
private val logger = LoggerFactory.getLogger(javaClass)

View File

@ -1,8 +0,0 @@
package be.simplenotes.search
import org.koin.dsl.module
import java.nio.file.Path
val searchModule = module {
single<NoteSearcher> { NoteSearcherImpl(Path.of(".lucene")) }
}

View File

@ -0,0 +1,14 @@
package be.simplenotes.search
import io.micronaut.context.annotation.Factory
import io.micronaut.context.annotation.Prototype
import java.nio.file.Path
import javax.inject.Named
@Factory
class SearchModule {
@Named("search-index")
@Prototype
internal fun luceneIndex() = Path.of(".lucene")
}

View File

@ -2,13 +2,16 @@ import be.simplenotes.Libs
plugins {
id("be.simplenotes.base")
kotlin("kapt")
}
dependencies {
implementation(project(":simplenotes-types"))
implementation(Libs.koinCore)
implementation(Libs.konform)
implementation(Libs.kotlinxHtml)
implementation(Libs.prettytime)
implementation(Libs.micronaut)
kapt(Libs.micronautProcessor)
}

View File

@ -3,8 +3,11 @@ package be.simplenotes.views
import be.simplenotes.types.LoggedInUser
import kotlinx.html.*
import kotlinx.html.ThScope.col
import javax.inject.Named
import javax.inject.Singleton
class BaseView(styles: String) : View(styles) {
@Singleton
class BaseView(@Named("styles") styles: String) : View(styles) {
fun renderHome(loggedInUser: LoggedInUser?) = renderPage(
title = "Home",
description = "A fast and simple note taking website",

View File

@ -4,8 +4,11 @@ import be.simplenotes.views.components.Alert
import be.simplenotes.views.components.alert
import kotlinx.html.a
import kotlinx.html.div
import javax.inject.Named
import javax.inject.Singleton
class ErrorView(styles: String) : View(styles) {
@Singleton
class ErrorView(@Named("styles") styles: String) : View(styles) {
enum class Type(val title: String) {
SqlTransientError("Database unavailable"),

View File

@ -6,8 +6,11 @@ import be.simplenotes.types.PersistedNoteMetadata
import be.simplenotes.views.components.*
import io.konform.validation.ValidationError
import kotlinx.html.*
import javax.inject.Named
import javax.inject.Singleton
class NoteView(styles: String) : View(styles) {
@Singleton
class NoteView(@Named("styles") styles: String) : View(styles) {
fun noteEditor(
loggedInUser: LoggedInUser,

View File

@ -8,8 +8,11 @@ import be.simplenotes.views.extensions.summary
import io.konform.validation.ValidationError
import kotlinx.html.*
import kotlinx.html.ButtonType.submit
import javax.inject.Named
import javax.inject.Singleton
class SettingView(styles: String) : View(styles) {
@Singleton
class SettingView(@Named("styles") styles: String) : View(styles) {
fun settings(
loggedInUser: LoggedInUser,

View File

@ -7,8 +7,11 @@ import be.simplenotes.views.components.input
import be.simplenotes.views.components.submitButton
import io.konform.validation.ValidationError
import kotlinx.html.*
import javax.inject.Named
import javax.inject.Singleton
class UserView(styles: String) : View(styles) {
@Singleton
class UserView(@Named("styles") styles: String) : View(styles) {
fun register(
loggedInUser: LoggedInUser?,
error: String? = null,

View File

@ -1,12 +0,0 @@
package be.simplenotes.views
import org.koin.core.qualifier.named
import org.koin.dsl.module
val viewModule = module {
single { ErrorView(get(named("styles"))) }
single { UserView(get(named("styles"))) }
single { BaseView(get(named("styles"))) }
single { SettingView(get(named("styles"))) }
single { NoteView(get(named("styles"))) }
}