Compare commits

...

2 Commits

Author SHA1 Message Date
31f538c7f5 Add python cli 2020-09-02 21:45:17 +02:00
c7cf71441f Add API 2020-09-02 21:41:57 +02:00
18 changed files with 515 additions and 22 deletions

3
.gitignore vendored
View File

@ -133,3 +133,6 @@ app/src/main/resources/static/styles*
# lucene index # lucene index
.lucene/ .lucene/
# python
__pycache__

View File

@ -1,5 +1,7 @@
package be.simplenotes.app package be.simplenotes.app
import be.simplenotes.app.api.ApiNoteController
import be.simplenotes.app.api.ApiUserController
import be.simplenotes.app.controllers.BaseController import be.simplenotes.app.controllers.BaseController
import be.simplenotes.app.controllers.NoteController import be.simplenotes.app.controllers.NoteController
import be.simplenotes.app.controllers.SettingsController import be.simplenotes.app.controllers.SettingsController
@ -7,6 +9,7 @@ import be.simplenotes.app.controllers.UserController
import be.simplenotes.app.filters.AuthFilter import be.simplenotes.app.filters.AuthFilter
import be.simplenotes.app.filters.AuthType import be.simplenotes.app.filters.AuthType
import be.simplenotes.app.filters.ErrorFilter import be.simplenotes.app.filters.ErrorFilter
import be.simplenotes.app.filters.JwtSource
import be.simplenotes.app.routes.Router import be.simplenotes.app.routes.Router
import be.simplenotes.app.utils.StaticFileResolver import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.app.utils.StaticFileResolverImpl import be.simplenotes.app.utils.StaticFileResolverImpl
@ -18,12 +21,22 @@ import be.simplenotes.persistance.persistanceModule
import be.simplenotes.search.searchModule import be.simplenotes.search.searchModule
import be.simplenotes.shared.config.DataSourceConfig import be.simplenotes.shared.config.DataSourceConfig
import be.simplenotes.shared.config.JwtConfig import be.simplenotes.shared.config.JwtConfig
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import org.http4k.core.RequestContexts import org.http4k.core.RequestContexts
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koin.core.qualifier.named import org.koin.core.qualifier.named
import org.koin.core.qualifier.qualifier import org.koin.core.qualifier.qualifier
import org.koin.dsl.module import org.koin.dsl.module
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.time.LocalDateTime
import java.util.*
import be.simplenotes.shared.config.ServerConfig as SimpleNotesServeConfig import be.simplenotes.shared.config.ServerConfig as SimpleNotesServeConfig
fun main() { fun main() {
@ -38,6 +51,7 @@ fun main() {
noteModule, noteModule,
settingsModule, settingsModule,
searchModule, searchModule,
apiModule,
) )
}.koin }.koin
@ -68,9 +82,12 @@ val serverModule = module {
get(), get(),
get(), get(),
get(), get(),
get(),
get(),
requiredAuth = get(AuthType.Required.qualifier), requiredAuth = get(AuthType.Required.qualifier),
optionalAuth = get(AuthType.Optional.qualifier), optionalAuth = get(AuthType.Optional.qualifier),
errorFilter = get(named("ErrorFilter")), errorFilter = get(named("ErrorFilter")),
apiAuth = get(named("apiAuthFilter")),
get() get()
)() )()
} }
@ -107,3 +124,57 @@ val configModule = module {
single { Config.jwtConfig } single { Config.jwtConfig }
single { Config.serverConfig } single { Config.serverConfig }
} }
val apiModule = module {
single { ApiUserController(get(), get()) }
single { ApiNoteController(get(), get()) }
single {
Json {
prettyPrint = true
serializersModule = get()
}
}
single {
SerializersModule {
contextual(LocalDateTime::class, LocalDateTimeSerializer)
contextual(UUID::class, UuidSerializer)
}
}
single(named("apiAuthFilter")) {
AuthFilter(
extractor = get(),
authType = AuthType.Required,
ctx = get(),
source = JwtSource.Header,
redirect = false
)()
}
}
internal object LocalDateTimeSerializer : KSerializer<LocalDateTime> {
override val descriptor: SerialDescriptor
get() = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: LocalDateTime) {
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): LocalDateTime {
TODO("Not implemented, isn't needed")
}
}
internal object UuidSerializer : KSerializer<UUID> {
override val descriptor: SerialDescriptor
get() = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: UUID) {
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): UUID {
TODO()
}
}

View File

@ -0,0 +1,77 @@
package be.simplenotes.app.api
import be.simplenotes.app.extensions.json
import be.simplenotes.app.utils.parseSearchTerms
import be.simplenotes.domain.model.PersistedNote
import be.simplenotes.domain.model.PersistedNoteMetadata
import be.simplenotes.domain.security.JwtPayload
import be.simplenotes.domain.usecases.NoteService
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.BAD_REQUEST
import org.http4k.core.Status.Companion.NOT_FOUND
import org.http4k.core.Status.Companion.OK
import org.http4k.routing.path
import java.util.*
class ApiNoteController(private val noteService: NoteService, private val json: Json) {
fun createNote(request: Request, jwtPayload: JwtPayload): Response {
val content = json.decodeFromString(NoteContent.serializer(), request.bodyString()).content
return noteService.create(jwtPayload.userId, content).fold(
{
Response(BAD_REQUEST)
},
{
Response(OK).json(json.encodeToString(UuidContent.serializer(), UuidContent(it.uuid)))
}
)
}
fun notes(request: Request, jwtPayload: JwtPayload): Response {
val notes = noteService.paginatedNotes(jwtPayload.userId, page = 1).notes
val json = json.encodeToString(ListSerializer(PersistedNoteMetadata.serializer()), notes)
return Response(OK).json(json)
}
fun note(request: Request, jwtPayload: JwtPayload): Response {
val uuid = request.path("uuid")!!
return noteService.find(jwtPayload.userId, UUID.fromString(uuid))
?.let { Response(OK).json(json.encodeToString(PersistedNote.serializer(), it)) }
?: Response(NOT_FOUND)
}
fun update(request: Request, jwtPayload: JwtPayload): Response {
val uuid = UUID.fromString(request.path("uuid")!!)
val content = json.decodeFromString(NoteContent.serializer(), request.bodyString()).content
return noteService.update(jwtPayload.userId, uuid, content).fold({
Response(BAD_REQUEST)
}, {
if (it == null) Response(NOT_FOUND)
else Response(OK)
})
}
fun search(request: Request, jwtPayload: JwtPayload): Response {
val query = json.decodeFromString(SearchContent.serializer(), request.bodyString()).query
val terms = parseSearchTerms(query)
val notes = noteService.search(jwtPayload.userId, terms)
val json = json.encodeToString(ListSerializer(PersistedNoteMetadata.serializer()), notes)
return Response(OK).json(json)
}
}
@Serializable
data class NoteContent(val content: String)
@Serializable
data class UuidContent(@Contextual val uuid: UUID)
@Serializable
data class SearchContent(@Contextual val query: String)

View File

@ -0,0 +1,26 @@
package be.simplenotes.app.api
import be.simplenotes.app.extensions.json
import be.simplenotes.domain.usecases.UserService
import be.simplenotes.domain.usecases.users.login.LoginForm
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status
class ApiUserController(private val userService: UserService, private val json: Json) {
fun login(request: Request): Response {
val form = json.decodeFromString(LoginForm.serializer(), request.bodyString())
val result = userService.login(form)
return result.fold({
Response(Status.BAD_REQUEST)
}, {
Response(Status.OK).json(json.encodeToString(Token.serializer(), Token(it)))
})
}
}
@Serializable
data class Token(val token: String)

View File

@ -9,6 +9,8 @@ fun Response.html(html: String) = body(html)
.header("Content-Type", "text/html; charset=utf-8") .header("Content-Type", "text/html; charset=utf-8")
.header("Cache-Control", "no-cache") .header("Cache-Control", "no-cache")
fun Response.json(json: String) = body(json).header("Content-Type", "application/json")
fun Response.Companion.redirect(url: String, permanent: Boolean = false) = fun Response.Companion.redirect(url: String, permanent: Boolean = false) =
Response(if (permanent) MOVED_PERMANENTLY else FOUND).header("Location", url) Response(if (permanent) MOVED_PERMANENTLY else FOUND).header("Location", url)

View File

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

View File

@ -6,14 +6,9 @@ import org.http4k.core.Method
import org.http4k.core.Request import org.http4k.core.Request
object ImmutableFilter { object ImmutableFilter {
operator fun invoke(): Filter { operator fun invoke() = Filter { next: HttpHandler ->
return Filter { next: HttpHandler -> { request: Request ->
{ request: Request -> next(request).header("Cache-Control", "public, max-age=31536000, immutable")
val response = next(request)
if (request.method == Method.GET)
response.header("Cache-Control", "public, max-age=31536000, immutable")
else response
}
} }
} }
} }

View File

@ -1,5 +1,7 @@
package be.simplenotes.app.routes package be.simplenotes.app.routes
import be.simplenotes.app.api.ApiNoteController
import be.simplenotes.app.api.ApiUserController
import be.simplenotes.app.controllers.BaseController import be.simplenotes.app.controllers.BaseController
import be.simplenotes.app.controllers.NoteController import be.simplenotes.app.controllers.NoteController
import be.simplenotes.app.controllers.SettingsController import be.simplenotes.app.controllers.SettingsController
@ -9,8 +11,7 @@ import be.simplenotes.app.filters.SecurityFilter
import be.simplenotes.app.filters.jwtPayload import be.simplenotes.app.filters.jwtPayload
import be.simplenotes.domain.security.JwtPayload import be.simplenotes.domain.security.JwtPayload
import org.http4k.core.* import org.http4k.core.*
import org.http4k.core.Method.GET import org.http4k.core.Method.*
import org.http4k.core.Method.POST
import org.http4k.filter.ResponseFilters import org.http4k.filter.ResponseFilters
import org.http4k.filter.ServerFilters.InitialiseRequestContext import org.http4k.filter.ServerFilters.InitialiseRequestContext
import org.http4k.routing.* import org.http4k.routing.*
@ -20,9 +21,12 @@ class Router(
private val userController: UserController, private val userController: UserController,
private val noteController: NoteController, private val noteController: NoteController,
private val settingsController: SettingsController, private val settingsController: SettingsController,
private val apiUserController: ApiUserController,
private val apiNoteController: ApiNoteController,
private val requiredAuth: Filter, private val requiredAuth: Filter,
private val optionalAuth: Filter, private val optionalAuth: Filter,
private val errorFilter: Filter, private val errorFilter: Filter,
private val apiAuth: Filter,
private val contexts: RequestContexts, private val contexts: RequestContexts,
) { ) {
operator fun invoke(): RoutingHttpHandler { operator fun invoke(): RoutingHttpHandler {
@ -61,10 +65,24 @@ class Router(
"/notes/deleted/{uuid}" bind POST protected noteController::deleted, "/notes/deleted/{uuid}" bind POST protected 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 protected apiNoteController::createNote,
"/api/notes/search" bind POST protected apiNoteController::search,
"/api/notes/{uuid}" bind GET protected apiNoteController::note,
"/api/notes/{uuid}" bind PUT protected apiNoteController::update,
)
val routes = routes( val routes = routes(
basicRoutes, basicRoutes,
optionalAuth.then(publicRoutes), optionalAuth.then(publicRoutes),
requiredAuth.then(protectedRoutes), requiredAuth.then(protectedRoutes),
apiAuth.then(protectedApiRoutes),
apiRoutes,
) )
val globalFilters = errorFilter val globalFilters = errorFilter

38
cli/Config.py Normal file
View File

@ -0,0 +1,38 @@
import configparser
import os
from pathlib import Path
import appdirs
import click
class Config:
def __init__(self):
config_dir: str = appdirs.user_config_dir("SimpleNotesCli")
Path(config_dir).mkdir(exist_ok=True)
self.file = os.path.join(config_dir, "config.ini")
self.configparser = configparser.ConfigParser()
self.token = None
self.base_url = None
def load(self):
self.configparser.read(self.file)
self.token = self.configparser.get("DEFAULT", "token", fallback=None)
self.base_url = self.configparser.get(
"DEFAULT", "base_url", fallback="https://simplenotes.be"
)
def save(self):
if self.token:
self.configparser.set("DEFAULT", "token", self.token)
if self.base_url:
self.configparser.set("DEFAULT", "base_url", self.base_url)
try:
with open(self.file, "w") as f:
self.configparser.write(f)
except IOError:
click.secho("An error occurred while saving config", fg="red", err=True)
exit(1)

66
cli/SimpleNotesApi.py Normal file
View File

@ -0,0 +1,66 @@
from typing import List, Optional
import click
import requests
from requests.models import Response
from domain import NoteMetadata
class SimplenotesApi:
def __init__(self, base_url: str):
self.base_url = base_url
self.s = requests.Session()
self.s.hooks["response"] = [self.__exit_unauthorized]
def __url(self, path: str) -> str:
return f"{self.base_url}{path}"
def __exit_unauthorized(self, response: Response, *args, **kwargs):
if response.status_code == 401:
click.secho("Unauthorized, please login again", fg="red", err=True)
exit(1)
def login(self, username: str, password: str) -> Optional[str]:
url = self.__url("/api/login")
r = self.s.post(
url,
json={"username": username, "password": password},
)
if r.status_code == 200:
return r.json()["token"]
def set_token(self, token: str):
self.s.headers.update({"Authorization": f"Bearer {token}"})
def find_note(self, uuid: str) -> Optional[str]:
url = self.__url(f"/api/notes/{uuid}")
r = self.s.get(url)
if r.status_code == 200:
return r.json()["markdown"]
def list_notes(self) -> List[NoteMetadata]:
url = self.__url("/api/notes")
r = self.s.get(url)
return list(map(lambda x: NoteMetadata(**x), r.json()))
def search_notes(self, query: str) -> List[NoteMetadata]:
url = self.__url("/api/notes/search")
r = self.s.post(url, json={"query": query})
if r.status_code == 200:
j = r.json()
return list(map(lambda x: NoteMetadata(**x), j))
else:
return []
def create_note(self, content: str) -> Optional[str]:
url = self.__url("/api/notes/")
r = self.s.post(url, json={"content": content})
if r.status_code == 200:
return r.json()["uuid"]
def update_note(self, uuid: str, content: str) -> bool:
url = self.__url(f"/api/notes/{uuid}")
r = self.s.put(url, json={"content": content})
return r.status_code == 200

112
cli/SimpleNotesCli.py Executable file
View File

@ -0,0 +1,112 @@
import click
from click import Context
import SimpleNotesApi
import utils
from Config import Config
from jwtutils import is_expired
from utils import edit_md
api: SimpleNotesApi
base_url: str
conf = Config()
@click.group()
@click.pass_context
def cli(ctx: Context):
global base_url
global api
conf.load()
base_url = conf.base_url
api = SimpleNotesApi.SimplenotesApi(base_url)
if ctx.invoked_subcommand == "login" or ctx.invoked_subcommand == "config":
return
token = conf.token
if token is None:
click.secho("Please login", err=True, fg="red")
exit(1)
elif is_expired(token):
click.secho("Login expired, please login again", err=True, fg="red")
exit(1)
else:
api.set_token(token)
@cli.command()
@click.option("--username", prompt=True)
@click.option("--password", prompt=True, hide_input=True)
def login(username: str, password: str):
token = api.login(username, password)
if token:
conf.token = token
conf.save()
click.secho(f"Welcome {username}", fg="green")
else:
click.echo("Invalid credentials")
exit(1)
@cli.command()
@click.option("--url", prompt=True)
def config(url: str):
conf.base_url = url
conf.save()
@cli.command(name="list")
def list_notes():
utils.print_notes(api.list_notes())
@cli.command()
@click.argument("uuid")
def edit(uuid: str):
note = api.find_note(uuid)
if not note:
click.secho("Note not found", err=True, fg="red")
exit(1)
edited = edit_md(note)
if edited == note:
exit(1)
if not api.update_note(uuid, edited):
click.secho("An error occurred", err=True, fg="red")
exit(1)
else:
utils.print_note_url(uuid, "updated", conf.base_url)
@cli.command()
def new():
placeholder = "---\ntitle: ''\ntags: []\n---\n"
md = edit_md(placeholder)
if md == placeholder:
exit(1)
uuid = api.create_note(md)
if uuid:
utils.print_note_url(uuid, "created", conf.base_url)
else:
click.secho("An error occurred", err=True, fg="red")
exit(1)
@cli.command(name="search")
@click.argument("search", nargs=-1, required=True)
def search_notes(search: str):
query = " ".join(search)
notes = api.search_notes(query)
if not notes:
print("No match")
else:
utils.print_notes(notes)
if __name__ == "__main__":
cli()

12
cli/domain.py Normal file
View File

@ -0,0 +1,12 @@
from typing import List
class NoteMetadata:
def __init__(self, uuid: str, title: str, tags: List[str], updatedAt: str):
self.uuid = uuid
self.title = title
self.tags = tags
self.updated_at = updatedAt
def __str__(self) -> str:
return f"NoteMetadata(uuid={self.uuid}, title={self.title}, tags={self.tags})"

10
cli/jwtutils.py Normal file
View File

@ -0,0 +1,10 @@
from datetime import datetime
import jwt
def is_expired(token: str) -> bool:
exp = jwt.decode(token, verify=False)["exp"]
dt = datetime.utcfromtimestamp(exp)
now = datetime.utcnow()
return dt < now

6
cli/requirements.txt Normal file
View File

@ -0,0 +1,6 @@
requests==2.24.0
python-editor==1.0.4
click==7.1.2
PyJWT==1.7.1
appdirs==1.4.4
tabulate==0.8.7

32
cli/utils.py Normal file
View File

@ -0,0 +1,32 @@
from typing import List
import editor
import click
from tabulate import tabulate
from domain import NoteMetadata
def edit_md(content: str = "") -> str:
b = bytes(content, "utf-8")
result = editor.edit(contents=b, suffix=".md")
return result.decode("utf-8")
def print_notes(notes: List[NoteMetadata]):
data = []
for n in notes:
uuid = click.style(n.uuid, fg="blue")
title = click.style(n.title, fg="green", bold=True)
tags = ["#" + e for e in n.tags]
tags = " ".join(tags)
data.append([uuid, title, tags])
headers = ["UUID", "title", "tags"]
click.echo(tabulate(data, headers=headers))
def print_note_url(uuid: str, action: str, base_url: str):
url = f"{base_url}/notes/{uuid}"
s = click.style(url, fg="green", underline=True)
click.echo(f"Note {action}: {s}")

View File

@ -5,30 +5,34 @@ import kotlinx.serialization.Serializable
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.* import java.util.*
@Serializable
data class NoteMetadata( data class NoteMetadata(
val title: String, val title: String,
val tags: List<String>, val tags: List<String>,
) )
@Serializable
data class PersistedNoteMetadata( data class PersistedNoteMetadata(
val title: String, val title: String,
val tags: List<String>, val tags: List<String>,
val updatedAt: LocalDateTime, @Contextual val updatedAt: LocalDateTime,
val uuid: UUID, @Contextual val uuid: UUID,
) )
@Serializable
data class Note( data class Note(
val meta: NoteMetadata, val meta: NoteMetadata,
val markdown: String, val markdown: String,
val html: String, val html: String,
) )
@Serializable
data class PersistedNote( data class PersistedNote(
val meta: NoteMetadata, val meta: NoteMetadata,
val markdown: String, val markdown: String,
val html: String, val html: String,
val updatedAt: LocalDateTime, @Contextual val updatedAt: LocalDateTime,
val uuid: UUID, @Contextual val uuid: UUID,
val public: Boolean, val public: Boolean,
) )

View File

@ -12,6 +12,7 @@ import be.simplenotes.domain.usecases.repositories.NoteRepository
import be.simplenotes.domain.usecases.repositories.UserRepository import be.simplenotes.domain.usecases.repositories.UserRepository
import be.simplenotes.domain.usecases.search.NoteSearcher import be.simplenotes.domain.usecases.search.NoteSearcher
import be.simplenotes.domain.usecases.search.SearchTerms import be.simplenotes.domain.usecases.search.SearchTerms
import kotlinx.serialization.Serializable
import java.util.* import java.util.*
class NoteService( class NoteService(

View File

@ -2,6 +2,7 @@ package be.simplenotes.domain.usecases.users.login
import arrow.core.Either import arrow.core.Either
import io.konform.validation.ValidationErrors import io.konform.validation.ValidationErrors
import kotlinx.serialization.Serializable
sealed class LoginError sealed class LoginError
object Unregistered : LoginError() object Unregistered : LoginError()
@ -10,6 +11,7 @@ class InvalidLoginForm(val validationErrors: ValidationErrors) : LoginError()
typealias Token = String typealias Token = String
@Serializable
data class LoginForm(val username: String?, val password: String?) data class LoginForm(val username: String?, val password: String?)
interface LoginUseCase { interface LoginUseCase {