From c7cf71441fb4a8726e23fc0b9988b5903c763842 Mon Sep 17 00:00:00 2001 From: Hubert Van De Walle Date: Wed, 2 Sep 2020 21:41:57 +0200 Subject: [PATCH] Add API --- app/src/main/kotlin/SimpleNotes.kt | 71 +++++++++++++++++ app/src/main/kotlin/api/ApiNoteController.kt | 77 +++++++++++++++++++ app/src/main/kotlin/api/ApiUserController.kt | 26 +++++++ .../kotlin/extensions/Http4kExtensions.kt | 2 + app/src/main/kotlin/filters/AuthFilter.kt | 34 ++++++-- .../main/kotlin/filters/ImmutableFilter.kt | 11 +-- app/src/main/kotlin/routes/Router.kt | 22 +++++- domain/src/main/kotlin/model/Note.kt | 12 ++- .../src/main/kotlin/usecases/NoteService.kt | 1 + .../usecases/users/login/LoginUsecase.kt | 2 + 10 files changed, 236 insertions(+), 22 deletions(-) create mode 100644 app/src/main/kotlin/api/ApiNoteController.kt create mode 100644 app/src/main/kotlin/api/ApiUserController.kt diff --git a/app/src/main/kotlin/SimpleNotes.kt b/app/src/main/kotlin/SimpleNotes.kt index 7b4c6a1..c68bc02 100644 --- a/app/src/main/kotlin/SimpleNotes.kt +++ b/app/src/main/kotlin/SimpleNotes.kt @@ -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 { + 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 { + 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() + } +} + diff --git a/app/src/main/kotlin/api/ApiNoteController.kt b/app/src/main/kotlin/api/ApiNoteController.kt new file mode 100644 index 0000000..27f0808 --- /dev/null +++ b/app/src/main/kotlin/api/ApiNoteController.kt @@ -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) diff --git a/app/src/main/kotlin/api/ApiUserController.kt b/app/src/main/kotlin/api/ApiUserController.kt new file mode 100644 index 0000000..bb1eb96 --- /dev/null +++ b/app/src/main/kotlin/api/ApiUserController.kt @@ -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) diff --git a/app/src/main/kotlin/extensions/Http4kExtensions.kt b/app/src/main/kotlin/extensions/Http4kExtensions.kt index 583fa7e..1c251ae 100644 --- a/app/src/main/kotlin/extensions/Http4kExtensions.kt +++ b/app/src/main/kotlin/extensions/Http4kExtensions.kt @@ -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) diff --git a/app/src/main/kotlin/filters/AuthFilter.kt b/app/src/main/kotlin/filters/AuthFilter.kt index 2223741..1a86dec 100644 --- a/app/src/main/kotlin/filters/AuthFilter.kt +++ b/app/src/main/kotlin/filters/AuthFilter.kt @@ -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() diff --git a/app/src/main/kotlin/filters/ImmutableFilter.kt b/app/src/main/kotlin/filters/ImmutableFilter.kt index 05bd19c..e8a3a33 100644 --- a/app/src/main/kotlin/filters/ImmutableFilter.kt +++ b/app/src/main/kotlin/filters/ImmutableFilter.kt @@ -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") } } } diff --git a/app/src/main/kotlin/routes/Router.kt b/app/src/main/kotlin/routes/Router.kt index 3c6e0a6..cf684e2 100644 --- a/app/src/main/kotlin/routes/Router.kt +++ b/app/src/main/kotlin/routes/Router.kt @@ -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 diff --git a/domain/src/main/kotlin/model/Note.kt b/domain/src/main/kotlin/model/Note.kt index 6ec61dc..c2697b4 100644 --- a/domain/src/main/kotlin/model/Note.kt +++ b/domain/src/main/kotlin/model/Note.kt @@ -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, ) +@Serializable data class PersistedNoteMetadata( val title: String, val tags: List, - 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, ) diff --git a/domain/src/main/kotlin/usecases/NoteService.kt b/domain/src/main/kotlin/usecases/NoteService.kt index 7efe8ef..830343c 100644 --- a/domain/src/main/kotlin/usecases/NoteService.kt +++ b/domain/src/main/kotlin/usecases/NoteService.kt @@ -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( diff --git a/domain/src/main/kotlin/usecases/users/login/LoginUsecase.kt b/domain/src/main/kotlin/usecases/users/login/LoginUsecase.kt index 22a6df7..3bdb243 100644 --- a/domain/src/main/kotlin/usecases/users/login/LoginUsecase.kt +++ b/domain/src/main/kotlin/usecases/users/login/LoginUsecase.kt @@ -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 {