Remove Boilerplate Use-case thingy

This commit is contained in:
Hubert Van De Walle 2021-03-03 16:36:08 +01:00
parent 3e1683dfe5
commit a4bf998c5b
37 changed files with 382 additions and 495 deletions

View File

@ -9,9 +9,8 @@ charset = utf-8
trim_trailing_whitespace = true trim_trailing_whitespace = true
insert_final_newline = true insert_final_newline = true
[*.{kt, kts}] [*.{kt,kts}]
indent_size = 4 indent_size = 4
insert_final_newline = true insert_final_newline = true
max_line_length = 120 max_line_length = 120
disabled_rules = no-wildcard-imports disabled_rules = no-wildcard-imports,import-ordering
kotlin_imports_layout = idea

View File

@ -1,7 +1,7 @@
package be.simplenotes.app.api package be.simplenotes.app.api
import be.simplenotes.app.extensions.auto import be.simplenotes.app.extensions.auto
import be.simplenotes.domain.usecases.NoteService import be.simplenotes.domain.NoteService
import be.simplenotes.types.LoggedInUser import be.simplenotes.types.LoggedInUser
import be.simplenotes.types.PersistedNote import be.simplenotes.types.PersistedNote
import be.simplenotes.types.PersistedNoteMetadata import be.simplenotes.types.PersistedNoteMetadata
@ -76,4 +76,4 @@ data class NoteContent(val content: String)
data class UuidContent(@Contextual val uuid: UUID) data class UuidContent(@Contextual val uuid: UUID)
@Serializable @Serializable
data class SearchContent(@Contextual val query: String) data class SearchContent(val query: String)

View File

@ -1,8 +1,8 @@
package be.simplenotes.app.api package be.simplenotes.app.api
import be.simplenotes.app.extensions.auto import be.simplenotes.app.extensions.auto
import be.simplenotes.domain.usecases.UserService import be.simplenotes.domain.LoginForm
import be.simplenotes.domain.usecases.users.login.LoginForm import be.simplenotes.domain.UserService
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.http4k.core.Request import org.http4k.core.Request

View File

@ -1,6 +1,6 @@
package be.simplenotes.app.controllers package be.simplenotes.app.controllers
import be.simplenotes.domain.usecases.HealthCheckService import be.simplenotes.domain.HealthCheckService
import org.http4k.core.Request import org.http4k.core.Request
import org.http4k.core.Response import org.http4k.core.Response
import org.http4k.core.Status.Companion.OK import org.http4k.core.Status.Companion.OK

View File

@ -2,10 +2,8 @@ package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html import be.simplenotes.app.extensions.html
import be.simplenotes.app.extensions.redirect import be.simplenotes.app.extensions.redirect
import be.simplenotes.domain.usecases.NoteService import be.simplenotes.domain.MarkdownParsingError
import be.simplenotes.domain.usecases.markdown.InvalidMeta import be.simplenotes.domain.NoteService
import be.simplenotes.domain.usecases.markdown.MissingMeta
import be.simplenotes.domain.usecases.markdown.ValidationError
import be.simplenotes.types.LoggedInUser import be.simplenotes.types.LoggedInUser
import be.simplenotes.views.NoteView import be.simplenotes.views.NoteView
import org.http4k.core.Method import org.http4k.core.Method
@ -34,17 +32,17 @@ class NoteController(
return noteService.create(loggedInUser, markdownForm).fold( return noteService.create(loggedInUser, markdownForm).fold(
{ {
val html = when (it) { val html = when (it) {
MissingMeta -> view.noteEditor( MarkdownParsingError.MissingMeta -> view.noteEditor(
loggedInUser, loggedInUser,
error = "Missing note metadata", error = "Missing note metadata",
textarea = markdownForm textarea = markdownForm
) )
InvalidMeta -> view.noteEditor( MarkdownParsingError.InvalidMeta -> view.noteEditor(
loggedInUser, loggedInUser,
error = "Invalid note metadata", error = "Invalid note metadata",
textarea = markdownForm textarea = markdownForm
) )
is ValidationError -> view.noteEditor( is MarkdownParsingError.ValidationError -> view.noteEditor(
loggedInUser, loggedInUser,
validationErrors = it.validationErrors, validationErrors = it.validationErrors,
textarea = markdownForm textarea = markdownForm
@ -113,17 +111,17 @@ class NoteController(
return noteService.update(loggedInUser, note.uuid, markdownForm).fold( return noteService.update(loggedInUser, note.uuid, markdownForm).fold(
{ {
val html = when (it) { val html = when (it) {
MissingMeta -> view.noteEditor( MarkdownParsingError.MissingMeta -> view.noteEditor(
loggedInUser, loggedInUser,
error = "Missing note metadata", error = "Missing note metadata",
textarea = markdownForm textarea = markdownForm
) )
InvalidMeta -> view.noteEditor( MarkdownParsingError.InvalidMeta -> view.noteEditor(
loggedInUser, loggedInUser,
error = "Invalid note metadata", error = "Invalid note metadata",
textarea = markdownForm textarea = markdownForm
) )
is ValidationError -> view.noteEditor( is MarkdownParsingError.ValidationError -> view.noteEditor(
loggedInUser, loggedInUser,
validationErrors = it.validationErrors, validationErrors = it.validationErrors,
textarea = markdownForm textarea = markdownForm

View File

@ -2,9 +2,10 @@ package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html import be.simplenotes.app.extensions.html
import be.simplenotes.app.extensions.redirect import be.simplenotes.app.extensions.redirect
import be.simplenotes.domain.usecases.UserService import be.simplenotes.domain.DeleteError
import be.simplenotes.domain.usecases.users.delete.DeleteError import be.simplenotes.domain.DeleteForm
import be.simplenotes.domain.usecases.users.delete.DeleteForm import be.simplenotes.domain.ExportService
import be.simplenotes.domain.UserService
import be.simplenotes.types.LoggedInUser import be.simplenotes.types.LoggedInUser
import be.simplenotes.views.SettingView import be.simplenotes.views.SettingView
import org.http4k.core.* import org.http4k.core.*
@ -15,6 +16,7 @@ import javax.inject.Singleton
@Singleton @Singleton
class SettingsController( class SettingsController(
private val userService: UserService, private val userService: UserService,
private val exportService: ExportService,
private val settingView: SettingView, private val settingView: SettingView,
) { ) {
fun settings(request: Request, loggedInUser: LoggedInUser): Response { fun settings(request: Request, loggedInUser: LoggedInUser): Response {
@ -61,15 +63,15 @@ class SettingsController(
return if (isDownload) { return if (isDownload) {
val filename = "simplenotes-export-${loggedInUser.username}" val filename = "simplenotes-export-${loggedInUser.username}"
if (request.form("format") == "zip") { if (request.form("format") == "zip") {
val zip = userService.exportAsZip(loggedInUser.userId) val zip = exportService.exportAsZip(loggedInUser.userId)
Response(Status.OK) Response(Status.OK)
.with(attachment("$filename.zip", "application/zip")) .with(attachment("$filename.zip", "application/zip"))
.body(zip) .body(zip)
} else } else
Response(Status.OK) Response(Status.OK)
.with(attachment("$filename.json", "application/json")) .with(attachment("$filename.json", "application/json"))
.body(userService.exportAsJson(loggedInUser.userId)) .body(exportService.exportAsJson(loggedInUser.userId))
} else Response(Status.OK).body(userService.exportAsJson(loggedInUser.userId)).header( } else Response(Status.OK).body(exportService.exportAsJson(loggedInUser.userId)).header(
"Content-Type", "Content-Type",
"application/json" "application/json"
) )

View File

@ -4,11 +4,7 @@ import be.simplenotes.app.extensions.html
import be.simplenotes.app.extensions.isSecure import be.simplenotes.app.extensions.isSecure
import be.simplenotes.app.extensions.redirect import be.simplenotes.app.extensions.redirect
import be.simplenotes.config.JwtConfig import be.simplenotes.config.JwtConfig
import be.simplenotes.domain.usecases.UserService import be.simplenotes.domain.*
import be.simplenotes.domain.usecases.users.login.*
import be.simplenotes.domain.usecases.users.register.InvalidRegisterForm
import be.simplenotes.domain.usecases.users.register.RegisterForm
import be.simplenotes.domain.usecases.users.register.UserExists
import be.simplenotes.types.LoggedInUser import be.simplenotes.types.LoggedInUser
import be.simplenotes.views.UserView import be.simplenotes.views.UserView
import org.http4k.core.Method.GET import org.http4k.core.Method.GET
@ -39,11 +35,11 @@ class UserController(
return result.fold( return result.fold(
{ {
val html = when (it) { val html = when (it) {
UserExists -> userView.register( RegisterError.UserExists -> userView.register(
loggedInUser, loggedInUser,
error = "User already exists" error = "User already exists"
) )
is InvalidRegisterForm -> is RegisterError.InvalidRegisterForm ->
userView.register( userView.register(
loggedInUser, loggedInUser,
validationErrors = it.validationErrors validationErrors = it.validationErrors
@ -70,17 +66,17 @@ class UserController(
return result.fold( return result.fold(
{ {
val html = when (it) { val html = when (it) {
Unregistered -> LoginError.Unregistered ->
userView.login( userView.login(
loggedInUser, loggedInUser,
error = "User does not exist" error = "User does not exist"
) )
WrongPassword -> LoginError.WrongPassword ->
userView.login( userView.login(
loggedInUser, loggedInUser,
error = "Wrong password" error = "Wrong password"
) )
is InvalidLoginForm -> is LoginError.InvalidLoginForm ->
userView.login( userView.login(
loggedInUser, loggedInUser,
validationErrors = it.validationErrors validationErrors = it.validationErrors

View File

@ -25,5 +25,7 @@ tasks.withType<JavaCompile> {
options.encoding = "UTF-8" options.encoding = "UTF-8"
} }
sourceSets["main"].resources.srcDirs("resources") sourceSets["main"].resources.setSrcDirs(listOf("resources"))
sourceSets["test"].resources.srcDirs("testresources") sourceSets["main"].java.setSrcDirs(emptyList<String>())
sourceSets["test"].resources.setSrcDirs(listOf("testresources"))
sourceSets["test"].java.setSrcDirs(emptyList<String>())

View File

@ -25,5 +25,5 @@ tasks.withType<KotlinCompile> {
} }
} }
kotlin.sourceSets["main"].kotlin.srcDirs("src") kotlin.sourceSets["main"].kotlin.setSrcDirs(listOf("src"))
kotlin.sourceSets["test"].kotlin.srcDirs("test") kotlin.sourceSets["test"].kotlin.setSrcDirs(listOf("test"))

View File

@ -1,8 +1,7 @@
package be.simplenotes.domain.usecases.export package be.simplenotes.domain
import be.simplenotes.persistence.repositories.NoteRepository import be.simplenotes.persistence.repositories.NoteRepository
import be.simplenotes.types.ExportedNote import be.simplenotes.types.ExportedNote
import io.micronaut.context.annotation.Primary
import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
@ -12,12 +11,17 @@ import java.io.ByteArrayOutputStream
import java.io.InputStream import java.io.InputStream
import javax.inject.Singleton import javax.inject.Singleton
@Primary interface ExportService {
fun exportAsJson(userId: Int): String
fun exportAsZip(userId: Int): InputStream
}
@Singleton @Singleton
internal class ExportUseCaseImpl( internal class ExportServiceImpl(
private val noteRepository: NoteRepository, private val noteRepository: NoteRepository,
private val json: Json, private val json: Json,
) : ExportUseCase { ) : ExportService {
override fun exportAsJson(userId: Int): String { override fun exportAsJson(userId: Int): String {
val notes = noteRepository.export(userId) val notes = noteRepository.export(userId)
return json.encodeToString(ListSerializer(ExportedNote.serializer()), notes) return json.encodeToString(ListSerializer(ExportedNote.serializer()), notes)
@ -38,7 +42,7 @@ internal class ExportUseCaseImpl(
} }
} }
class ZipOutput : AutoCloseable { private class ZipOutput : AutoCloseable {
val outputStream = ByteArrayOutputStream() val outputStream = ByteArrayOutputStream()
private val zipOutputStream = ZipArchiveOutputStream(outputStream) private val zipOutputStream = ZipArchiveOutputStream(outputStream)

View File

@ -1,4 +1,4 @@
package be.simplenotes.domain.usecases package be.simplenotes.domain
import be.simplenotes.persistence.DbHealthCheck import be.simplenotes.persistence.DbHealthCheck
import javax.inject.Singleton import javax.inject.Singleton

View File

@ -1 +0,0 @@
package be.simplenotes.domain

View File

@ -1,4 +1,4 @@
package be.simplenotes.domain.usecases.markdown package be.simplenotes.domain
import arrow.core.Either import arrow.core.Either
import arrow.core.computations.either import arrow.core.computations.either
@ -8,38 +8,43 @@ import be.simplenotes.domain.validation.NoteValidations
import be.simplenotes.types.NoteMetadata import be.simplenotes.types.NoteMetadata
import com.vladsch.flexmark.html.HtmlRenderer import com.vladsch.flexmark.html.HtmlRenderer
import com.vladsch.flexmark.parser.Parser import com.vladsch.flexmark.parser.Parser
import io.konform.validation.ValidationErrors
import org.yaml.snakeyaml.Yaml import org.yaml.snakeyaml.Yaml
import org.yaml.snakeyaml.parser.ParserException import org.yaml.snakeyaml.parser.ParserException
import org.yaml.snakeyaml.scanner.ScannerException import org.yaml.snakeyaml.scanner.ScannerException
import javax.inject.Singleton import javax.inject.Singleton
interface MarkdownService {
fun renderDocument(input: String): Either<MarkdownParsingError, Document>
}
private typealias MetaMdPair = Pair<String, String> private typealias MetaMdPair = Pair<String, String>
@Singleton @Singleton
internal class MarkdownConverterImpl( internal class MarkdownServiceImpl(
private val parser: Parser, private val parser: Parser,
private val renderer: HtmlRenderer, private val renderer: HtmlRenderer,
) : MarkdownConverter { ) : MarkdownService {
private val yamlBoundPattern = "-{3}".toRegex() private val yamlBoundPattern = "-{3}".toRegex()
private fun splitMetaFromDocument(input: String): Either<MissingMeta, MetaMdPair> { private fun splitMetaFromDocument(input: String): Either<MarkdownParsingError.MissingMeta, MetaMdPair> {
val split = input.split(yamlBoundPattern, 3) val split = input.split(yamlBoundPattern, 3)
if (split.size < 3) return MissingMeta.left() if (split.size < 3) return MarkdownParsingError.MissingMeta.left()
return (split[1].trim() to split[2].trim()).right() return (split[1].trim() to split[2].trim()).right()
} }
private val yaml = Yaml() private val yaml = Yaml()
private fun parseMeta(input: String): Either<InvalidMeta, NoteMetadata> { private fun parseMeta(input: String): Either<MarkdownParsingError.InvalidMeta, NoteMetadata> {
val load: Map<String, Any> = try { val load: Map<String, Any> = try {
yaml.load(input) yaml.load(input)
} catch (e: ParserException) { } catch (e: ParserException) {
return InvalidMeta.left() return MarkdownParsingError.InvalidMeta.left()
} catch (e: ScannerException) { } catch (e: ScannerException) {
return InvalidMeta.left() return MarkdownParsingError.InvalidMeta.left()
} }
val title = when (val titleNode = load["title"]) { val title = when (val titleNode = load["title"]) {
is String, is Number -> titleNode.toString() is String, is Number -> titleNode.toString()
else -> return InvalidMeta.left() else -> return MarkdownParsingError.InvalidMeta.left()
} }
val tagsNode = load["tags"] val tagsNode = load["tags"]
@ -61,3 +66,11 @@ internal class MarkdownConverterImpl(
Document(parsedMeta, html) Document(parsedMeta, html)
} }
} }
sealed class MarkdownParsingError {
object MissingMeta : MarkdownParsingError()
object InvalidMeta : MarkdownParsingError()
class ValidationError(val validationErrors: ValidationErrors) : MarkdownParsingError()
}
data class Document(val metadata: NoteMetadata, val html: String)

View File

@ -1,10 +1,8 @@
package be.simplenotes.domain.usecases package be.simplenotes.domain
import arrow.core.computations.either import arrow.core.computations.either
import be.simplenotes.domain.security.HtmlSanitizer import be.simplenotes.domain.security.HtmlSanitizer
import be.simplenotes.domain.usecases.markdown.MarkdownConverter import be.simplenotes.domain.utils.parseSearchTerms
import be.simplenotes.domain.usecases.markdown.MarkdownParsingError
import be.simplenotes.domain.usecases.search.parseSearchTerms
import be.simplenotes.persistence.repositories.NoteRepository import be.simplenotes.persistence.repositories.NoteRepository
import be.simplenotes.persistence.repositories.UserRepository import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.persistence.transactions.TransactionService import be.simplenotes.persistence.transactions.TransactionService
@ -20,7 +18,7 @@ import javax.inject.Singleton
@Singleton @Singleton
class NoteService( class NoteService(
private val markdownConverter: MarkdownConverter, private val markdownService: MarkdownService,
private val noteRepository: NoteRepository, private val noteRepository: NoteRepository,
private val userRepository: UserRepository, private val userRepository: UserRepository,
private val searcher: NoteSearcher, private val searcher: NoteSearcher,
@ -30,29 +28,23 @@ class NoteService(
fun create(user: LoggedInUser, markdownText: String) = transaction.use { fun create(user: LoggedInUser, markdownText: String) = transaction.use {
either.eager<MarkdownParsingError, PersistedNote> { either.eager<MarkdownParsingError, PersistedNote> {
val persistedNote = !markdownConverter.renderDocument(markdownText) markdownService.renderDocument(markdownText)
.map { it.copy(html = htmlSanitizer.sanitize(user, it.html)) } .map { it.copy(html = htmlSanitizer.sanitize(user, it.html)) }
.map { Note(it.metadata, markdown = markdownText, html = it.html) } .map { Note(it.metadata, markdown = markdownText, html = it.html) }
.map { noteRepository.create(user.userId, it) } .map { noteRepository.create(user.userId, it) }
.bind()
searcher.indexNote(user.userId, persistedNote) .also { searcher.indexNote(user.userId, it) }
persistedNote
} }
} }
fun update( fun update(user: LoggedInUser, uuid: UUID, markdownText: String) = transaction.use {
user: LoggedInUser,
uuid: UUID,
markdownText: String,
) = transaction.use {
either.eager<MarkdownParsingError, PersistedNote?> { either.eager<MarkdownParsingError, PersistedNote?> {
val persistedNote = !markdownConverter.renderDocument(markdownText) markdownService.renderDocument(markdownText)
.map { it.copy(html = htmlSanitizer.sanitize(user, it.html)) } .map { it.copy(html = htmlSanitizer.sanitize(user, it.html)) }
.map { Note(it.metadata, markdown = markdownText, html = it.html) } .map { Note(it.metadata, markdown = markdownText, html = it.html) }
.map { noteRepository.update(user.userId, uuid, it) } .map { noteRepository.update(user.userId, uuid, it) }
.bind()
persistedNote?.let { searcher.updateIndex(user.userId, it) } ?.also { searcher.updateIndex(user.userId, it) }
persistedNote
} }
} }

92
domain/src/UserService.kt Normal file
View File

@ -0,0 +1,92 @@
package be.simplenotes.domain
import arrow.core.Either
import arrow.core.computations.either
import arrow.core.filterOrElse
import arrow.core.leftIfNull
import arrow.core.rightIfNotNull
import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.domain.validation.UserValidations
import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.persistence.transactions.TransactionService
import be.simplenotes.search.NoteSearcher
import be.simplenotes.types.LoggedInUser
import be.simplenotes.types.PersistedUser
import io.konform.validation.ValidationErrors
import kotlinx.serialization.Serializable
import javax.inject.Singleton
interface UserService {
fun register(form: RegisterForm): Either<RegisterError, PersistedUser>
fun login(form: LoginForm): Either<LoginError, Token>
fun delete(form: DeleteForm): Either<DeleteError, Unit>
}
@Singleton
internal class UserServiceImpl(
private val userRepository: UserRepository,
private val passwordHash: PasswordHash,
private val jwt: SimpleJwt<LoggedInUser>,
private val searcher: NoteSearcher,
private val transactionService: TransactionService,
) : UserService {
override fun register(form: RegisterForm) = transactionService.use {
UserValidations.validateRegister(form)
.filterOrElse({ !userRepository.exists(it.username) }, { RegisterError.UserExists })
.map { it.copy(password = passwordHash.crypt(it.password)) }
.map { userRepository.create(it) }
.leftIfNull { RegisterError.UserExists }
}
override fun login(form: LoginForm) = either.eager<LoginError, Token> {
UserValidations.validateLogin(form)
.bind()
.let { userRepository.find(it.username) }
.rightIfNotNull { LoginError.Unregistered }
.filterOrElse({ passwordHash.verify(form.password!!, it.password) }, { LoginError.WrongPassword })
.map { jwt.sign(LoggedInUser(it)) }
.bind()
}
override fun delete(form: DeleteForm) = transactionService.use {
either.eager<DeleteError, Unit> {
val user = !UserValidations.validateDelete(form)
val persistedUser = !userRepository.find(user.username).rightIfNotNull { DeleteError.Unregistered }
!Either.conditionally(
passwordHash.verify(user.password, persistedUser.password),
{ DeleteError.WrongPassword },
{ }
)
!Either.conditionally(userRepository.delete(persistedUser.id), { DeleteError.Unregistered }, { })
searcher.dropIndex(persistedUser.id)
}
}
}
sealed class DeleteError {
object Unregistered : DeleteError()
object WrongPassword : DeleteError()
class InvalidForm(val validationErrors: ValidationErrors) : DeleteError()
}
data class DeleteForm(val username: String?, val password: String?, val checked: Boolean)
sealed class LoginError {
object Unregistered : LoginError()
object WrongPassword : LoginError()
class InvalidLoginForm(val validationErrors: ValidationErrors) : LoginError()
}
typealias Token = String
sealed class RegisterError {
object UserExists : RegisterError()
class InvalidRegisterForm(val validationErrors: ValidationErrors) : RegisterError()
}
typealias RegisterForm = LoginForm
@Serializable
data class LoginForm(val username: String?, val password: String?)

View File

@ -1,4 +1,4 @@
package be.simplenotes.domain.usecases.markdown package be.simplenotes.domain.modules
import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension
import com.vladsch.flexmark.html.HtmlRenderer import com.vladsch.flexmark.html.HtmlRenderer

View File

@ -1,18 +0,0 @@
package be.simplenotes.domain.usecases
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,
deleteUseCase: DeleteUseCase,
exportUseCase: ExportUseCase,
) : LoginUseCase by loginUseCase,
RegisterUseCase by registerUseCase,
DeleteUseCase by deleteUseCase,
ExportUseCase by exportUseCase

View File

@ -1,8 +0,0 @@
package be.simplenotes.domain.usecases.export
import java.io.InputStream
interface ExportUseCase {
fun exportAsJson(userId: Int): String
fun exportAsZip(userId: Int): InputStream
}

View File

@ -1,16 +0,0 @@
package be.simplenotes.domain.usecases.markdown
import arrow.core.Either
import be.simplenotes.types.NoteMetadata
import io.konform.validation.ValidationErrors
sealed class MarkdownParsingError
object MissingMeta : MarkdownParsingError()
object InvalidMeta : MarkdownParsingError()
class ValidationError(val validationErrors: ValidationErrors) : MarkdownParsingError()
data class Document(val metadata: NoteMetadata, val html: String)
interface MarkdownConverter {
fun renderDocument(input: String): Either<MarkdownParsingError, Document>
}

View File

@ -1,96 +0,0 @@
package be.simplenotes.domain.usecases.search
import be.simplenotes.search.SearchTerms
import java.util.*
private enum class Quote { SingleQuote, DoubleQuote, }
data class ParsedSearchInput(val global: List<String>, val entries: Map<String, String>)
object SearchInputParser {
fun parseInput(input: String): ParsedSearchInput {
val tokenizer = StringTokenizer(input, ":\"' ", true)
val tokens = ArrayList<String>()
val current = StringBuilder()
var quoteOpen: Quote? = null
fun push() {
if (current.isNotEmpty()) {
tokens.add(current.toString())
}
current.setLength(0)
quoteOpen = null
}
while (tokenizer.hasMoreTokens()) {
when (val token = tokenizer.nextToken()) {
"\"" -> when {
Quote.DoubleQuote == quoteOpen -> push()
quoteOpen == null -> quoteOpen = Quote.DoubleQuote
else -> current.append(token)
}
"'" -> when {
Quote.SingleQuote == quoteOpen -> push()
quoteOpen == null -> quoteOpen = Quote.SingleQuote
else -> current.append(token)
}
" " -> {
if (quoteOpen != null) current.append(" ")
else push()
}
":" -> {
push()
tokens.add(token)
}
else -> {
current.append(token)
}
}
}
push()
val entries = HashMap<String, String>()
val colonIndexes = ArrayList<Int>()
tokens.forEachIndexed { index, token ->
if (token == ":") colonIndexes += index
}
var changes = 0
for (colonIndex in colonIndexes) {
val offset = changes * 3
val key = tokens.getOrNull(colonIndex - 1 - offset)
val value = tokens.getOrNull(colonIndex + 1 - offset)
if (key != null && value != null) {
entries[key] = value
tokens.removeAt(colonIndex - 1 - offset) // remove key
tokens.removeAt(colonIndex - 1 - offset) // remove :
tokens.removeAt(colonIndex - 1 - offset) // remove value
changes++
}
}
return ParsedSearchInput(global = tokens, entries = entries)
}
}
internal fun parseSearchTerms(input: String): SearchTerms {
val parsedInput = SearchInputParser.parseInput(input)
val title: String? = parsedInput.entries["title"]
val tag: String? = parsedInput.entries["tag"]
val content: String? = parsedInput.entries["content"]
val all = parsedInput.global.takeIf { it.isNotEmpty() }?.joinToString(" ")
return SearchTerms(
title = title,
tag = tag,
content = content,
all = all
)
}

View File

@ -1,35 +0,0 @@
package be.simplenotes.domain.usecases.users.delete
import arrow.core.Either
import arrow.core.computations.either
import arrow.core.rightIfNotNull
import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.validation.UserValidations
import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.persistence.transactions.TransactionService
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,
private val searcher: NoteSearcher,
private val transactionService: TransactionService,
) : DeleteUseCase {
override fun delete(form: DeleteForm) = transactionService.use {
either.eager<DeleteError, Unit> {
val user = !UserValidations.validateDelete(form)
val persistedUser = !userRepository.find(user.username).rightIfNotNull { DeleteError.Unregistered }
!Either.conditionally(
passwordHash.verify(user.password, persistedUser.password),
{ DeleteError.WrongPassword },
{ Unit }
)
!Either.conditionally(userRepository.delete(persistedUser.id), { DeleteError.Unregistered }, { Unit })
searcher.dropIndex(persistedUser.id)
}
}
}

View File

@ -1,16 +0,0 @@
package be.simplenotes.domain.usecases.users.delete
import arrow.core.Either
import io.konform.validation.ValidationErrors
sealed class DeleteError {
object Unregistered : DeleteError()
object WrongPassword : DeleteError()
class InvalidForm(val validationErrors: ValidationErrors) : DeleteError()
}
data class DeleteForm(val username: String?, val password: String?, val checked: Boolean)
interface DeleteUseCase {
fun delete(form: DeleteForm): Either<DeleteError, Unit>
}

View File

@ -1,28 +0,0 @@
package be.simplenotes.domain.usecases.users.login
import arrow.core.computations.either
import arrow.core.filterOrElse
import arrow.core.rightIfNotNull
import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.domain.validation.UserValidations
import be.simplenotes.persistence.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,
private val jwt: SimpleJwt<LoggedInUser>
) : LoginUseCase {
override fun login(form: LoginForm) = either.eager<LoginError, Token> {
val user = !UserValidations.validateLogin(form)
!userRepository.find(user.username)
.rightIfNotNull { Unregistered }
.filterOrElse({ passwordHash.verify(form.password!!, it.password) }, { WrongPassword })
.map { jwt.sign(LoggedInUser(it)) }
}
}

View File

@ -1,19 +0,0 @@
package be.simplenotes.domain.usecases.users.login
import arrow.core.Either
import io.konform.validation.ValidationErrors
import kotlinx.serialization.Serializable
sealed class LoginError
object Unregistered : LoginError()
object WrongPassword : LoginError()
class InvalidLoginForm(val validationErrors: ValidationErrors) : LoginError()
typealias Token = String
@Serializable
data class LoginForm(val username: String?, val password: String?)
interface LoginUseCase {
fun login(form: LoginForm): Either<LoginError, Token>
}

View File

@ -1,28 +0,0 @@
package be.simplenotes.domain.usecases.users.register
import arrow.core.Either
import arrow.core.filterOrElse
import arrow.core.leftIfNull
import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.validation.UserValidations
import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.persistence.transactions.TransactionService
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,
private val transactionService: TransactionService,
) : RegisterUseCase {
override fun register(form: RegisterForm): Either<RegisterError, PersistedUser> = transactionService.use {
UserValidations.validateRegister(form)
.filterOrElse({ !userRepository.exists(it.username) }, { UserExists })
.map { it.copy(password = passwordHash.crypt(it.password)) }
.map { userRepository.create(it) }
.leftIfNull { UserExists }
}
}

View File

@ -1,16 +0,0 @@
package be.simplenotes.domain.usecases.users.register
import arrow.core.Either
import be.simplenotes.domain.usecases.users.login.LoginForm
import be.simplenotes.types.PersistedUser
import io.konform.validation.ValidationErrors
sealed class RegisterError
object UserExists : RegisterError()
class InvalidRegisterForm(val validationErrors: ValidationErrors) : RegisterError()
typealias RegisterForm = LoginForm
interface RegisterUseCase {
fun register(form: RegisterForm): Either<RegisterError, PersistedUser>
}

View File

@ -0,0 +1,94 @@
package be.simplenotes.domain.utils
import be.simplenotes.search.SearchTerms
import java.util.*
private enum class Quote { SingleQuote, DoubleQuote, }
private data class ParsedSearchInput(val global: List<String>, val entries: Map<String, String>)
private fun parseInput(input: String): ParsedSearchInput {
val tokenizer = StringTokenizer(input, ":\"' ", true)
val tokens = ArrayList<String>()
val current = StringBuilder()
var quoteOpen: Quote? = null
fun push() {
if (current.isNotEmpty()) {
tokens.add(current.toString())
}
current.setLength(0)
quoteOpen = null
}
while (tokenizer.hasMoreTokens()) {
when (val token = tokenizer.nextToken()) {
"\"" -> when {
Quote.DoubleQuote == quoteOpen -> push()
quoteOpen == null -> quoteOpen = Quote.DoubleQuote
else -> current.append(token)
}
"'" -> when {
Quote.SingleQuote == quoteOpen -> push()
quoteOpen == null -> quoteOpen = Quote.SingleQuote
else -> current.append(token)
}
" " -> {
if (quoteOpen != null) current.append(" ")
else push()
}
":" -> {
push()
tokens.add(token)
}
else -> {
current.append(token)
}
}
}
push()
val entries = HashMap<String, String>()
val colonIndexes = ArrayList<Int>()
tokens.forEachIndexed { index, token ->
if (token == ":") colonIndexes += index
}
var changes = 0
for (colonIndex in colonIndexes) {
val offset = changes * 3
val key = tokens.getOrNull(colonIndex - 1 - offset)
val value = tokens.getOrNull(colonIndex + 1 - offset)
if (key != null && value != null) {
entries[key] = value
tokens.removeAt(colonIndex - 1 - offset) // remove key
tokens.removeAt(colonIndex - 1 - offset) // remove :
tokens.removeAt(colonIndex - 1 - offset) // remove value
changes++
}
}
return ParsedSearchInput(global = tokens, entries = entries)
}
internal fun parseSearchTerms(input: String): SearchTerms {
val parsedInput = parseInput(input)
val title: String? = parsedInput.entries["title"]
val tag: String? = parsedInput.entries["tag"]
val content: String? = parsedInput.entries["content"]
val all = parsedInput.global.takeIf { it.isNotEmpty() }?.joinToString(" ")
return SearchTerms(
title = title,
tag = tag,
content = content,
all = all
)
}

View File

@ -1,6 +1,6 @@
package be.simplenotes.domain.validation package be.simplenotes.domain.validation
import be.simplenotes.domain.usecases.markdown.ValidationError import be.simplenotes.domain.MarkdownParsingError
import be.simplenotes.types.NoteMetadata import be.simplenotes.types.NoteMetadata
import io.konform.validation.Validation import io.konform.validation.Validation
import io.konform.validation.jsonschema.maxItems import io.konform.validation.jsonschema.maxItems
@ -27,9 +27,9 @@ internal object NoteValidations {
} }
} }
fun validateMetadata(meta: NoteMetadata): ValidationError? { fun validateMetadata(meta: NoteMetadata): MarkdownParsingError.ValidationError? {
val errors = metaValidator.validate(meta).errors val errors = metaValidator.validate(meta).errors
return if (errors.isEmpty()) null return if (errors.isEmpty()) null
else return ValidationError(errors) else return MarkdownParsingError.ValidationError(errors)
} }
} }

View File

@ -3,12 +3,7 @@ package be.simplenotes.domain.validation
import arrow.core.Either import arrow.core.Either
import arrow.core.left import arrow.core.left
import arrow.core.right import arrow.core.right
import be.simplenotes.domain.usecases.users.delete.DeleteError import be.simplenotes.domain.*
import be.simplenotes.domain.usecases.users.delete.DeleteForm
import be.simplenotes.domain.usecases.users.login.InvalidLoginForm
import be.simplenotes.domain.usecases.users.login.LoginForm
import be.simplenotes.domain.usecases.users.register.InvalidRegisterForm
import be.simplenotes.domain.usecases.users.register.RegisterForm
import be.simplenotes.types.User import be.simplenotes.types.User
import io.konform.validation.Validation import io.konform.validation.Validation
import io.konform.validation.jsonschema.maxLength import io.konform.validation.jsonschema.maxLength
@ -26,16 +21,16 @@ internal object UserValidations {
} }
} }
fun validateLogin(form: LoginForm): Either<InvalidLoginForm, User> { fun validateLogin(form: LoginForm): Either<LoginError.InvalidLoginForm, User> {
val errors = loginValidator.validate(form).errors val errors = loginValidator.validate(form).errors
return if (errors.isEmpty()) User(form.username!!, form.password!!).right() return if (errors.isEmpty()) User(form.username!!, form.password!!).right()
else return InvalidLoginForm(errors).left() else return LoginError.InvalidLoginForm(errors).left()
} }
fun validateRegister(form: RegisterForm): Either<InvalidRegisterForm, User> { fun validateRegister(form: RegisterForm): Either<RegisterError.InvalidRegisterForm, User> {
val errors = loginValidator.validate(form).errors val errors = loginValidator.validate(form).errors
return if (errors.isEmpty()) User(form.username!!, form.password!!).right() return if (errors.isEmpty()) User(form.username!!, form.password!!).right()
else return InvalidRegisterForm(errors).left() else return RegisterError.InvalidRegisterForm(errors).left()
} }
private val deleteValidator = Validation<DeleteForm> { private val deleteValidator = Validation<DeleteForm> {

View File

@ -1 +0,0 @@
package be.simplenotes.domain

View File

@ -0,0 +1,101 @@
@file:Suppress("MemberVisibilityCanBePrivate")
package be.simplenotes.domain
import be.simplenotes.config.JwtConfig
import be.simplenotes.domain.security.BcryptPasswordHash
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.domain.security.UserJwtMapper
import be.simplenotes.domain.testutils.isLeftOfType
import be.simplenotes.domain.testutils.isRight
import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.persistence.transactions.TransactionService
import be.simplenotes.types.PersistedUser
import com.natpryce.hamkrest.assertion.assertThat
import com.natpryce.hamkrest.equalTo
import io.mockk.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.util.concurrent.TimeUnit
internal class UserServiceTest {
val userRepository = mockk<UserRepository>()
val passwordHash = BcryptPasswordHash(test = true)
val jwtConfig = JwtConfig("a secret", 1, TimeUnit.HOURS)
val simpleJwt = SimpleJwt(jwtConfig, UserJwtMapper())
val noopTransactionService = object : TransactionService {
override fun <T> use(block: () -> T) = block()
}
val userService = UserServiceImpl(
userRepository = userRepository,
passwordHash = passwordHash,
jwt = simpleJwt,
searcher = mockk(),
transactionService = noopTransactionService
)
@BeforeEach
fun resetMocks() {
clearMocks(userRepository)
}
@Test
fun `register should fail with invalid form`() {
val form = RegisterForm("", "a".repeat(10))
assertThat(userService.register(form), isLeftOfType<RegisterError.InvalidRegisterForm>())
verify { userRepository wasNot called }
}
@Test
fun `Register should fail with existing username`() {
val form = RegisterForm("someuser", "somepassword")
every { userRepository.exists(form.username!!) } returns true
assertThat(userService.register(form), isLeftOfType<RegisterError.UserExists>())
}
@Test
fun `Register should succeed with new user`() {
val form = RegisterForm("someuser", "somepassword")
every { userRepository.exists(form.username!!) } returns false
every { userRepository.create(any()) } returns PersistedUser(form.username!!, form.password!!, 1)
val res = userService.register(form)
assertThat(res, isRight())
res.map { assertThat(it.username, equalTo(form.username)) }
}
@Test
fun `Login should fail with invalid form`() {
val form = LoginForm("", "a")
assertThat(userService.login(form), isLeftOfType<LoginError.InvalidLoginForm>())
verify { userRepository wasNot called }
}
@Test
fun `Login should fail with non existing user`() {
val form = LoginForm("someusername", "somepassword")
every { userRepository.find(form.username!!) } returns null
assertThat(userService.login(form), isLeftOfType<LoginError.Unregistered>())
}
@Test
fun `Login should fail with wrong password`() {
val form = LoginForm("someusername", "wrongpassword")
every { userRepository.find(form.username!!) } returns
PersistedUser(form.username!!, passwordHash.crypt("right password"), 1)
assertThat(userService.login(form), isLeftOfType<LoginError.WrongPassword>())
}
@Test
fun `Login should succeed with existing user and correct password`() {
val loginForm = LoginForm("someusername", "somepassword")
every { userRepository.find(loginForm.username!!) } returns
PersistedUser(loginForm.username!!, passwordHash.crypt(loginForm.password!!), 1)
val res = userService.login(loginForm)
assertThat(res, isRight())
}
}

View File

@ -1,7 +1,7 @@
package be.simplenotes.domain.security package be.simplenotes.domain.security
import be.simplenotes.config.JwtConfig import be.simplenotes.config.JwtConfig
import be.simplenotes.domain.usecases.users.login.Token import be.simplenotes.domain.Token
import be.simplenotes.types.LoggedInUser import be.simplenotes.types.LoggedInUser
import com.auth0.jwt.JWT import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm import com.auth0.jwt.algorithms.Algorithm

View File

@ -1,65 +0,0 @@
package be.simplenotes.domain.usecases.users.login
import be.simplenotes.config.JwtConfig
import be.simplenotes.domain.security.BcryptPasswordHash
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.domain.security.UserJwtMapper
import be.simplenotes.domain.testutils.isLeftOfType
import be.simplenotes.domain.testutils.isRight
import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.types.PersistedUser
import com.natpryce.hamkrest.assertion.assertThat
import io.mockk.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.util.concurrent.TimeUnit
internal class LoginUseCaseImplTest {
// region setup
private val mockUserRepository = mockk<UserRepository>()
private val passwordHash = BcryptPasswordHash(test = true)
private val jwtConfig = JwtConfig("a secret", 1, TimeUnit.HOURS)
private val simpleJwt = SimpleJwt(jwtConfig, UserJwtMapper())
private val loginUseCase = LoginUseCaseImpl(mockUserRepository, passwordHash, simpleJwt)
@BeforeEach
fun resetMocks() {
clearMocks(mockUserRepository)
}
// endregion
@Test
fun `Login should fail with invalid form`() {
val form = LoginForm("", "a")
assertThat(loginUseCase.login(form), isLeftOfType<InvalidLoginForm>())
verify { mockUserRepository wasNot called }
}
@Test
fun `Login should fail with non existing user`() {
val form = LoginForm("someusername", "somepassword")
every { mockUserRepository.find(form.username!!) } returns null
assertThat(loginUseCase.login(form), isLeftOfType<Unregistered>())
}
@Test
fun `Login should fail with wrong password`() {
val form = LoginForm("someusername", "wrongpassword")
every { mockUserRepository.find(form.username!!) } returns
PersistedUser(form.username!!, passwordHash.crypt("right password"), 1)
assertThat(loginUseCase.login(form), isLeftOfType<WrongPassword>())
}
@Test
fun `Login should succeed with existing user and correct password`() {
val loginForm = LoginForm("someusername", "somepassword")
every { mockUserRepository.find(loginForm.username!!) } returns
PersistedUser(loginForm.username!!, passwordHash.crypt(loginForm.password!!), 1)
val res = loginUseCase.login(loginForm)
assertThat(res, isRight())
}
}

View File

@ -1,54 +0,0 @@
package be.simplenotes.domain.usecases.users.register
import be.simplenotes.domain.security.BcryptPasswordHash
import be.simplenotes.domain.testutils.isLeftOfType
import be.simplenotes.domain.testutils.isRight
import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.persistence.transactions.TransactionService
import be.simplenotes.types.PersistedUser
import com.natpryce.hamkrest.assertion.assertThat
import com.natpryce.hamkrest.equalTo
import io.mockk.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
internal class RegisterUseCaseImplTest {
// region setup
private val mockUserRepository = mockk<UserRepository>()
private val passwordHash = BcryptPasswordHash(test = true)
private val noopTransactionService = object : TransactionService {
override fun <T> use(block: () -> T) = block()
}
private val registerUseCase = RegisterUseCaseImpl(mockUserRepository, passwordHash, noopTransactionService)
@BeforeEach
fun resetMocks() {
clearMocks(mockUserRepository)
}
// endregion
@Test
fun `register should fail with invalid form`() {
val form = RegisterForm("", "a".repeat(10))
assertThat(registerUseCase.register(form), isLeftOfType<InvalidRegisterForm>())
verify { mockUserRepository wasNot called }
}
@Test
fun `Register should fail with existing username`() {
val form = RegisterForm("someuser", "somepassword")
every { mockUserRepository.exists(form.username!!) } returns true
assertThat(registerUseCase.register(form), isLeftOfType<UserExists>())
}
@Test
fun `Register should succeed with new user`() {
val form = RegisterForm("someuser", "somepassword")
every { mockUserRepository.exists(form.username!!) } returns false
every { mockUserRepository.create(any()) } returns PersistedUser(form.username!!, form.password!!, 1)
val res = registerUseCase.register(form)
assertThat(res, isRight())
res.map { assertThat(it.username, equalTo(form.username)) }
}
}

View File

@ -1,4 +1,4 @@
package be.simplenotes.domain.usecases.search package be.simplenotes.domain.utils
import be.simplenotes.search.SearchTerms import be.simplenotes.search.SearchTerms
import com.natpryce.hamkrest.assertion.assertThat import com.natpryce.hamkrest.assertion.assertThat

View File

@ -1,10 +1,10 @@
package be.simplenotes.domain.validation package be.simplenotes.domain.validation
import be.simplenotes.domain.LoginError
import be.simplenotes.domain.LoginForm
import be.simplenotes.domain.RegisterForm
import be.simplenotes.domain.testutils.isLeftOfType import be.simplenotes.domain.testutils.isLeftOfType
import be.simplenotes.domain.testutils.isRight import be.simplenotes.domain.testutils.isRight
import be.simplenotes.domain.usecases.users.login.InvalidLoginForm
import be.simplenotes.domain.usecases.users.login.LoginForm
import be.simplenotes.domain.usecases.users.register.RegisterForm
import com.natpryce.hamkrest.assertion.assertThat import com.natpryce.hamkrest.assertion.assertThat
import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Nested
import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.ParameterizedTest
@ -28,7 +28,7 @@ internal class UserValidationsTest {
@ParameterizedTest @ParameterizedTest
@MethodSource("invalidLoginForms") @MethodSource("invalidLoginForms")
fun `validate invalid logins`(form: LoginForm) { fun `validate invalid logins`(form: LoginForm) {
assertThat(UserValidations.validateLogin(form), isLeftOfType<InvalidLoginForm>()) assertThat(UserValidations.validateLogin(form), isLeftOfType<LoginError.InvalidLoginForm>())
} }
@Suppress("Unused") @Suppress("Unused")
@ -59,7 +59,7 @@ internal class UserValidationsTest {
@ParameterizedTest @ParameterizedTest
@MethodSource("invalidRegisterForms") @MethodSource("invalidRegisterForms")
fun `validate invalid register`(form: LoginForm) { fun `validate invalid register`(form: LoginForm) {
assertThat(UserValidations.validateLogin(form), isLeftOfType<InvalidLoginForm>()) assertThat(UserValidations.validateLogin(form), isLeftOfType<LoginError.InvalidLoginForm>())
} }
@Suppress("Unused") @Suppress("Unused")

View File

@ -37,7 +37,7 @@ class BaseView(@Named("styles") styles: String) : View(styles) {
attributes["aria-label"] = "demo-search" attributes["aria-label"] = "demo-search"
attributes["name"] = "search" attributes["name"] = "search"
attributes["disabled"] = "" attributes["disabled"] = ""
attributes["value"] = "tag:\"demo\"" attributes["value"] = "tag:demo"
} }
span { span {
id = "buttons" id = "buttons"