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/
# python
__pycache__

View File

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

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("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) =
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.domain.security.JwtPayload
import be.simplenotes.domain.security.JwtPayloadExtractor
import org.http4k.core.Filter
import org.http4k.core.Request
import org.http4k.core.RequestContexts
import org.http4k.core.Response
import org.http4k.core.*
import org.http4k.core.Status.Companion.UNAUTHORIZED
import org.http4k.core.cookie.cookie
enum class AuthType {
@ -18,17 +16,26 @@ private const val authKey = "auth"
class AuthFilter(
private val extractor: JwtPayloadExtractor,
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 ->
{
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 {
jwtPayload != null -> {
ctx[it][authKey] = jwtPayload
next(it)
}
authType == AuthType.Required -> Response.redirect("/login")
authType == AuthType.Required -> {
if (redirect) Response.redirect("/login")
else Response(UNAUTHORIZED)
}
else -> next(it)
}
}
@ -37,6 +44,17 @@ class AuthFilter(
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
?.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
object ImmutableFilter {
operator fun invoke(): Filter {
return Filter { next: HttpHandler ->
{ request: Request ->
val response = next(request)
if (request.method == Method.GET)
response.header("Cache-Control", "public, max-age=31536000, immutable")
else response
}
operator fun invoke() = Filter { next: HttpHandler ->
{ request: Request ->
next(request).header("Cache-Control", "public, max-age=31536000, immutable")
}
}
}

View File

@ -1,5 +1,7 @@
package be.simplenotes.app.routes
import be.simplenotes.app.api.ApiNoteController
import be.simplenotes.app.api.ApiUserController
import be.simplenotes.app.controllers.BaseController
import be.simplenotes.app.controllers.NoteController
import be.simplenotes.app.controllers.SettingsController
@ -9,8 +11,7 @@ import be.simplenotes.app.filters.SecurityFilter
import be.simplenotes.app.filters.jwtPayload
import be.simplenotes.domain.security.JwtPayload
import org.http4k.core.*
import org.http4k.core.Method.GET
import org.http4k.core.Method.POST
import org.http4k.core.Method.*
import org.http4k.filter.ResponseFilters
import org.http4k.filter.ServerFilters.InitialiseRequestContext
import org.http4k.routing.*
@ -20,9 +21,12 @@ class Router(
private val userController: UserController,
private val noteController: NoteController,
private val settingsController: SettingsController,
private val apiUserController: ApiUserController,
private val apiNoteController: ApiNoteController,
private val requiredAuth: Filter,
private val optionalAuth: Filter,
private val errorFilter: Filter,
private val apiAuth: Filter,
private val contexts: RequestContexts,
) {
operator fun invoke(): RoutingHttpHandler {
@ -61,10 +65,24 @@ class Router(
"/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(
basicRoutes,
optionalAuth.then(publicRoutes),
requiredAuth.then(protectedRoutes),
apiAuth.then(protectedApiRoutes),
apiRoutes,
)
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.util.*
@Serializable
data class NoteMetadata(
val title: String,
val tags: List<String>,
)
@Serializable
data class PersistedNoteMetadata(
val title: String,
val tags: List<String>,
val updatedAt: LocalDateTime,
val uuid: UUID,
@Contextual val updatedAt: LocalDateTime,
@Contextual val uuid: UUID,
)
@Serializable
data class Note(
val meta: NoteMetadata,
val markdown: String,
val html: String,
)
@Serializable
data class PersistedNote(
val meta: NoteMetadata,
val markdown: String,
val html: String,
val updatedAt: LocalDateTime,
val uuid: UUID,
@Contextual val updatedAt: LocalDateTime,
@Contextual val uuid: UUID,
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.search.NoteSearcher
import be.simplenotes.domain.usecases.search.SearchTerms
import kotlinx.serialization.Serializable
import java.util.*
class NoteService(

View File

@ -2,6 +2,7 @@ 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()
@ -10,6 +11,7 @@ class InvalidLoginForm(val validationErrors: ValidationErrors) : LoginError()
typealias Token = String
@Serializable
data class LoginForm(val username: String?, val password: String?)
interface LoginUseCase {