diff --git a/api-doc/users/index.apib b/api-doc/users/index.apib index 126ce93..e4fd27c 100644 --- a/api-doc/users/index.apib +++ b/api-doc/users/index.apib @@ -34,7 +34,7 @@ ## Authenticate user [/login] -Authenticate one user to access protected routes. +Authenticate one user to access protected routing. ### Authenticate a user [POST] diff --git a/api/http/test.http b/api/http/test.http index 6af1fc7..f80a543 100644 --- a/api/http/test.http +++ b/api/http/test.http @@ -7,25 +7,48 @@ Content-Type: application/json "password": "test" } -> {% client.global.set("token", response.body.token); %} +> {% +client.global.set("token", response.body.token); +client.test("Request executed successfully", function() { + client.assert(response.status === 200, "Response status is not 200"); +}); +%} ### Get notes GET http://localhost:8081/notes Authorization: Bearer {{token}} +> {% +client.test("Request executed successfully", function() { + client.assert(response.status === 200, "Response status is not 200"); +}); +%} + ### Create a note -POST http://localhost:8081/notes/babar +POST http://localhost:8081/notes/tortue Content-Type: application/json Authorization: Bearer {{token}} { - "tags": [ - "Test", - "Dev" - ] + "tags": [ + "Dev", + "Server" + ] } -### Create a note +> {% +client.test("Request executed successfully", function() { + client.assert(response.status === 201, "Response status is not 200"); +}); +%} + +### Read a note GET http://localhost:8081/notes/babar Content-Type: application/json Authorization: Bearer {{token}} + +> {% +client.test("Request executed successfully", function() { + client.assert(response.status === 200, "Response status is not 200"); +}); +%} diff --git a/api/pom.xml b/api/pom.xml index 8086402..4dc8a84 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -71,11 +71,6 @@ ktor-server-core ${ktor_version} - - io.ktor - ktor-locations - ${ktor_version} - io.ktor ktor-jackson diff --git a/api/src/NotesApplication.kt b/api/src/NotesApplication.kt index 5d3d97f..c1c3cff 100644 --- a/api/src/NotesApplication.kt +++ b/api/src/NotesApplication.kt @@ -1,15 +1,20 @@ package be.vandewalleh -import be.vandewalleh.controllers.KodeinController -import be.vandewalleh.controllers.controllerModule -import be.vandewalleh.features.Feature import be.vandewalleh.features.configurationFeature import be.vandewalleh.features.configurationModule import be.vandewalleh.features.features import be.vandewalleh.migrations.Migration +import be.vandewalleh.routing.registerRoutes +import be.vandewalleh.services.serviceModule import io.ktor.application.Application +import io.ktor.application.call +import io.ktor.application.feature import io.ktor.application.log -import io.ktor.auth.authenticate +import io.ktor.response.respond +import io.ktor.routing.Route +import io.ktor.routing.Routing +import io.ktor.routing.RoutingPath.Companion.root +import io.ktor.routing.get import io.ktor.routing.routing import me.liuwj.ktorm.database.Database import org.kodein.di.Kodein @@ -27,10 +32,10 @@ fun Application.module() { configurationFeature() kodein = Kodein { - import(controllerModule) import(configurationModule) + import(serviceModule) - bind() with singleton { Migration(this.kodein) } + bind() with singleton { Migration(this.kodein) } bind() with singleton { Database.Companion.connect(this.instance()) } } @@ -38,15 +43,21 @@ fun Application.module() { log.debug(kodein.container.tree.bindings.description()) - // TODO, clean this (migration) - val feature by kodein.instance() - feature.execute() - - val controllers by kodein.instance>() + val migration by kodein.instance() + migration.migrate() routing { - controllers.forEach { - it.apply { registerRoutes() } - } + registerRoutes(kodein) } + + val root = feature(Routing) + val allRoutes = allRoutes(root) + allRoutes.forEach { + println(it.toString()) + } + +} + +fun allRoutes(root: Route): List { + return listOf(root) + root.children.flatMap { allRoutes(it) } } \ No newline at end of file diff --git a/api/src/auth/AuthenticationModule.kt b/api/src/auth/AuthenticationModule.kt index 1a9de0f..f57ad63 100644 --- a/api/src/auth/AuthenticationModule.kt +++ b/api/src/auth/AuthenticationModule.kt @@ -11,7 +11,7 @@ import org.kodein.di.generic.instance fun Application.authenticationModule() { install(Authentication) { jwt { - val simpleJwt: SimpleJWT by kodein.instance() + val simpleJwt by kodein.instance() verifier(simpleJwt.verifier) validate { UserIdPrincipal(it.payload.getClaim("name").asString()) diff --git a/api/src/controllers/AuthCrudController.kt b/api/src/controllers/AuthCrudController.kt deleted file mode 100644 index ba57567..0000000 --- a/api/src/controllers/AuthCrudController.kt +++ /dev/null @@ -1,31 +0,0 @@ -package be.vandewalleh.controllers - -import io.ktor.application.ApplicationCall -import io.ktor.auth.UserIdPrincipal -import io.ktor.auth.authenticate -import io.ktor.auth.principal -import io.ktor.routing.Route -import io.ktor.routing.Routing -import io.ktor.routing.route -import org.kodein.di.Kodein - -abstract class AuthCrudController( - private val path: String, - override val kodein: Kodein -) : - KodeinController(kodein) { - - abstract val route: Route.() -> Unit - - fun ApplicationCall.userEmail(): String = - this.principal()!!.name - - override fun Routing.registerRoutes() { - authenticate { - route(path) { - route() - } - } - } -} - diff --git a/api/src/controllers/Controllers.kt b/api/src/controllers/Controllers.kt deleted file mode 100644 index 799361c..0000000 --- a/api/src/controllers/Controllers.kt +++ /dev/null @@ -1,19 +0,0 @@ -package be.vandewalleh.controllers - -import org.kodein.di.Kodein -import org.kodein.di.generic.bind -import org.kodein.di.generic.inSet -import org.kodein.di.generic.setBinding -import org.kodein.di.generic.singleton - -/** - * [Kodein] controller module containing the app controllers - */ -val controllerModule = Kodein.Module(name = "Controller") { - bind() from setBinding() - - bind().inSet() with singleton { UserController(this.kodein) } - bind().inSet() with singleton { HealthCheckController(this.kodein) } - bind().inSet() with singleton { NotesController(this.kodein) } - bind().inSet() with singleton { NotesTitleController(this.kodein) } -} \ No newline at end of file diff --git a/api/src/controllers/HealthCheckController.kt b/api/src/controllers/HealthCheckController.kt deleted file mode 100644 index 311ece3..0000000 --- a/api/src/controllers/HealthCheckController.kt +++ /dev/null @@ -1,21 +0,0 @@ -package be.vandewalleh.controllers - -import io.ktor.application.call -import io.ktor.locations.Location -import io.ktor.locations.get -import io.ktor.response.respondText -import io.ktor.routing.Routing -import org.kodein.di.Kodein - -class HealthCheckController(kodein: Kodein) : KodeinController(kodein) { - override fun Routing.registerRoutes() { - get { - call.respondText("pong") - } - } - - object Routes { - @Location("/ping") - class Ping - } -} \ No newline at end of file diff --git a/api/src/controllers/KodeinController.kt b/api/src/controllers/KodeinController.kt deleted file mode 100644 index 5231c49..0000000 --- a/api/src/controllers/KodeinController.kt +++ /dev/null @@ -1,21 +0,0 @@ -package be.vandewalleh.controllers - -import io.ktor.application.ApplicationCall -import io.ktor.http.HttpStatusCode -import io.ktor.response.respond -import io.ktor.routing.Routing -import org.kodein.di.Kodein -import org.kodein.di.KodeinAware - -abstract class KodeinController(override val kodein: Kodein) : KodeinAware { - - - /** - * Method that subtypes must override to register the handled [Routing] routes. - */ - abstract fun Routing.registerRoutes() - - suspend fun ApplicationCall.respondStatus(status: HttpStatusCode) { - this.respond(status, mapOf("message" to status.description)) - } -} diff --git a/api/src/controllers/NotesController.kt b/api/src/controllers/NotesController.kt deleted file mode 100644 index 99dc7ec..0000000 --- a/api/src/controllers/NotesController.kt +++ /dev/null @@ -1,59 +0,0 @@ -package be.vandewalleh.controllers - -import be.vandewalleh.tables.Notes -import be.vandewalleh.tables.Tags -import be.vandewalleh.tables.Users -import io.ktor.application.call -import io.ktor.response.respond -import io.ktor.response.respondText -import io.ktor.routing.Route -import io.ktor.routing.get -import me.liuwj.ktorm.database.Database -import me.liuwj.ktorm.dsl.* -import org.kodein.di.Kodein -import org.kodein.di.generic.instance -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter - -class NotesController(kodein: Kodein) : AuthCrudController("/notes", kodein) { - private val db by kodein.instance() - - private class ResponseItem(val title: String, val tags: List, val updatedAt: String) - - override val route: Route.() -> Unit = { - get { - val email = call.userEmail() - - val list = db.from(Notes) - .leftJoin(Users, on = Users.id eq Notes.userId) - .select(Notes.id, Notes.title, Notes.updatedAt) - .where { Users.email eq email } - .orderBy(Notes.updatedAt.desc()) - .map { row -> - Notes.createEntity(row) - } - .toList() - - val response = mutableListOf() - - list.forEach { note -> - val tags = db.from(Tags) - .select(Tags.name) - .where { Tags.noteId eq note.id } - .map { it[Tags.name]!! } - .toList() - - val updatedAt = DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(note.updatedAt) - - val item = ResponseItem( - title = note.title, - tags = tags, - updatedAt = updatedAt - ) - response += item - } - - call.respond(response) - } - } -} diff --git a/api/src/controllers/NotesTitleController.kt b/api/src/controllers/NotesTitleController.kt deleted file mode 100644 index fdb23ea..0000000 --- a/api/src/controllers/NotesTitleController.kt +++ /dev/null @@ -1,170 +0,0 @@ -package be.vandewalleh.controllers - -import be.vandewalleh.entities.Note -import be.vandewalleh.entities.Tag -import be.vandewalleh.entities.User -import be.vandewalleh.tables.Chapters -import be.vandewalleh.tables.Notes -import be.vandewalleh.tables.Tags -import be.vandewalleh.tables.Users -import io.ktor.application.ApplicationCall -import io.ktor.application.call -import io.ktor.http.HttpStatusCode -import io.ktor.request.receive -import io.ktor.response.respond -import io.ktor.routing.* -import me.liuwj.ktorm.database.Database -import me.liuwj.ktorm.dsl.* -import me.liuwj.ktorm.entity.add -import me.liuwj.ktorm.entity.find -import me.liuwj.ktorm.entity.sequenceOf -import org.kodein.di.Kodein -import org.kodein.di.generic.instance -import java.time.LocalDateTime - -class NotesTitleController(kodein: Kodein) : AuthCrudController("/notes/{noteTitle}", kodein) { - private val db by kodein.instance() - - private fun ApplicationCall.noteTitle(): String? { - return this.parameters["noteTitle"]!! - } - - private fun ApplicationCall.user(): User { - return db.sequenceOf(Users) - .find { it.email eq this.userEmail() } - ?: error("") - } - - /** - * Method that returns a [Notes] ID from it's title and the currently logged in user. - * returns null if none found - */ - private fun ApplicationCall.requestedNoteId(): Int? { - val user = user() - val title = noteTitle() ?: error("title missing") - - return db.from(Notes) - .select(Notes.id) - .where { Notes.userId eq user.id and (Notes.title eq title) } - .limit(0, 1) - .map { it[Notes.id]!! } - .firstOrNull() - } - - private class PostRequestBody(val tags: List) - - private class ChapterDto(val title: String, val content: String) - private class GetResponseBody(val tags: List, val chapters: List) - - private class PatchRequestBody(val title: String? = null, val tags: List? = null) - - override val route: Route.() -> Unit = { - post { - val title = call.noteTitle() ?: error("") - val tags = call.receive().tags - - val user = call.user() - - val exists = call.requestedNoteId() != null - - if (exists) { - return@post call.respondStatus(HttpStatusCode.Conflict) - } - - db.useTransaction { - val note = Note { - this.title = title - this.user = user - this.updatedAt = LocalDateTime.now() - } - - db.sequenceOf(Notes).add(note) - - tags.forEach { tagName -> - val tag = Tag { - this.note = note - this.name = tagName - } - - db.sequenceOf(Tags).add(tag) - } - } - - call.respondStatus(HttpStatusCode.Created) - } - - get { - val noteId = call.requestedNoteId() - ?: return@get call.respondStatus(HttpStatusCode.NotFound) - - val tags = db.from(Tags) - .select(Tags.name) - .where { Tags.noteId eq noteId } - .map { it[Tags.name]!! } - .toList() - - val chapters = db.from(Chapters) - .select(Chapters.title, Chapters.content) - .where { Chapters.noteId eq noteId } - .orderBy(Chapters.number.asc()) - .map { ChapterDto(it[Chapters.title]!!, it[Chapters.content]!!) } - .toList() - - val response = GetResponseBody(tags, chapters) - - call.respond(response) - } - - patch { - val requestedChanges = call.receive() - - // This means no changes have been requested.. - if (requestedChanges.tags == null && requestedChanges.title == null) { - return@patch call.respondStatus(HttpStatusCode.BadRequest) - } - - val noteId = call.requestedNoteId() - ?: return@patch call.respondStatus(HttpStatusCode.NotFound) - - db.useTransaction { - if (requestedChanges.title != null) { - db.update(Notes) { - it.title to requestedChanges.title - where { it.id eq noteId } - } - } - - if (requestedChanges.tags != null) { - // delete all tags - db.delete(Tags) { - it.noteId eq noteId - } - - // put new ones - requestedChanges.tags.forEach { tagName -> - db.insert(Tags) { - it.name to tagName - it.noteId to noteId - } - } - } - - } - - call.respondStatus(HttpStatusCode.OK) - } - - delete { - val noteId = call.requestedNoteId() - ?: return@delete call.respondStatus(HttpStatusCode.NotFound) - - db.useTransaction { - db.delete(Tags) { it.noteId eq noteId } - db.delete(Chapters) { it.noteId eq noteId } - db.delete(Notes) { it.id eq noteId } - } - - call.respondStatus(HttpStatusCode.OK) - } - } -} \ No newline at end of file diff --git a/api/src/controllers/UserController.kt b/api/src/controllers/UserController.kt deleted file mode 100644 index 0c69c41..0000000 --- a/api/src/controllers/UserController.kt +++ /dev/null @@ -1,90 +0,0 @@ -package be.vandewalleh.controllers - -import be.vandewalleh.auth.SimpleJWT -import be.vandewalleh.auth.UsernamePasswordCredential -import be.vandewalleh.entities.User -import be.vandewalleh.errors.ApiError -import be.vandewalleh.tables.Users -import io.ktor.application.call -import io.ktor.http.HttpStatusCode -import io.ktor.locations.Location -import io.ktor.locations.post -import io.ktor.request.receive -import io.ktor.response.respond -import io.ktor.routing.Routing -import me.liuwj.ktorm.database.Database -import me.liuwj.ktorm.dsl.* -import me.liuwj.ktorm.entity.add -import me.liuwj.ktorm.entity.sequenceOf -import org.kodein.di.Kodein -import org.kodein.di.generic.instance -import org.mindrot.jbcrypt.BCrypt -import java.time.LocalDateTime - -class UserController(kodein: Kodein) : KodeinController(kodein) { - private val simpleJwt by instance() - private val db by instance() - - override fun Routing.registerRoutes() { - - - post { - data class Response(val token: String) - - val credential = call.receive() - - val (email, password) = db.from(Users) - .select(Users.email, Users.password) - .where { Users.username eq credential.username } - .map { row -> row[Users.email]!! to row[Users.password]!! } - .firstOrNull() - ?: return@post call.respond(HttpStatusCode.Unauthorized, ApiError.InvalidCredentialError) - - - if (!BCrypt.checkpw(credential.password, password)) { - return@post call.respond(HttpStatusCode.Unauthorized, ApiError.InvalidCredentialError) - } - - return@post call.respond(Response(simpleJwt.sign(email))) - } - - post { - data class Response(val message: String) - - val user = call.receive() - - val exists = db.from(Users) - .select() - .where { (Users.username eq user.username) or (Users.email eq user.email) } - .any() - - if (exists) { - return@post call.respond(HttpStatusCode.Conflict, ApiError.ExistingUserError) - } - - val hashedPassword = BCrypt.hashpw(user.password, BCrypt.gensalt()) - - val newUser = User { - this.username = user.username - this.email = user.email - this.password = hashedPassword - this.createdAt = LocalDateTime.now() - } - - db.sequenceOf(Users).add(newUser) - - return@post call.respond(HttpStatusCode.Created, Response("User created successfully")) - } - } - - object Routes { - @Location("/login") - class Login - - @Location("/register") - class Register - - } -} - -data class RegisterInfo(val username: String, val email: String, val password: String) \ No newline at end of file diff --git a/api/src/errors/Errors.kt b/api/src/errors/Errors.kt deleted file mode 100644 index c917a24..0000000 --- a/api/src/errors/Errors.kt +++ /dev/null @@ -1,7 +0,0 @@ -package be.vandewalleh.errors - -sealed class ApiError(val message: String){ - object InvalidCredentialError : ApiError("Invalid credentials") - object ExistingUserError : ApiError("User already exists") - object DeletedUserError : ApiError("User has been deleted") -} diff --git a/api/src/extensions/ApplicationCallExtensions.kt b/api/src/extensions/ApplicationCallExtensions.kt new file mode 100644 index 0000000..de8ee00 --- /dev/null +++ b/api/src/extensions/ApplicationCallExtensions.kt @@ -0,0 +1,35 @@ +package be.vandewalleh.extensions + +import be.vandewalleh.kodein +import be.vandewalleh.services.UserService +import io.ktor.application.ApplicationCall +import io.ktor.auth.UserIdPrincipal +import io.ktor.auth.principal +import io.ktor.http.HttpStatusCode +import io.ktor.request.receive +import io.ktor.response.respond +import org.kodein.di.generic.instance + +val userService by kodein.instance() + +suspend fun ApplicationCall.respondStatus(status: HttpStatusCode) { + respond(status, status.description) +} + +/** + * @return the userId for the currently authenticated user + */ +fun ApplicationCall.userId(): Int { + val email = principal()!!.name + return userService.getUserId(email)!! +} + +private class Tags(val tags: List) + +suspend fun ApplicationCall.receiveTags(): List { + return receive().tags +} + +data class NotePatch(val tags: List?, val title: String?) + +suspend fun ApplicationCall.receiveNotePatch() = receive() \ No newline at end of file diff --git a/api/src/extensions/ParametersExtensions.kt b/api/src/extensions/ParametersExtensions.kt new file mode 100644 index 0000000..9b0b208 --- /dev/null +++ b/api/src/extensions/ParametersExtensions.kt @@ -0,0 +1,22 @@ +package be.vandewalleh.extensions + +import be.vandewalleh.kodein +import be.vandewalleh.services.NotesService +import be.vandewalleh.tables.Notes +import io.ktor.http.Parameters +import org.kodein.di.generic.instance + +val notesService by kodein.instance() + +fun Parameters.noteTitle(): String { + return this["noteTitle"]!! +} + +/** + * Method that returns a [Notes] ID from it's title and the currently logged in user. + * returns null if none found + */ +fun Parameters.noteId(userId: Int): Int? { + val title = noteTitle() + return notesService.getNoteIdFromUserIdAndTitle(userId, title) +} \ No newline at end of file diff --git a/api/src/features/ErrorFeature.kt b/api/src/features/ErrorFeature.kt new file mode 100644 index 0000000..c085ab2 --- /dev/null +++ b/api/src/features/ErrorFeature.kt @@ -0,0 +1,17 @@ +package be.vandewalleh.features + +import io.ktor.application.Application +import io.ktor.application.call +import io.ktor.application.install +import io.ktor.features.StatusPages +import io.ktor.http.HttpStatusCode +import io.ktor.response.respond +import io.ktor.utils.io.errors.IOException + +fun Application.handleErrors() { + install(StatusPages) { + exception { _ -> + call.respond(HttpStatusCode.BadRequest) + } + } +} \ No newline at end of file diff --git a/api/src/features/Features.kt b/api/src/features/Features.kt index 2ecaec2..09e5942 100644 --- a/api/src/features/Features.kt +++ b/api/src/features/Features.kt @@ -2,16 +2,10 @@ package be.vandewalleh.features import be.vandewalleh.auth.authenticationModule import io.ktor.application.Application -import org.kodein.di.Kodein -import org.kodein.di.KodeinAware fun Application.features() { - locationFeature() corsFeature() contentNegotiationFeature() authenticationModule() + handleErrors() } - -abstract class Feature(override val kodein: Kodein) : KodeinAware { - abstract fun execute() -} \ No newline at end of file diff --git a/api/src/features/LocationFeature.kt b/api/src/features/LocationFeature.kt deleted file mode 100644 index 571246e..0000000 --- a/api/src/features/LocationFeature.kt +++ /dev/null @@ -1,9 +0,0 @@ -package be.vandewalleh.features - -import io.ktor.application.Application -import io.ktor.application.install -import io.ktor.locations.Locations - -fun Application.locationFeature() { - install(Locations) -} \ No newline at end of file diff --git a/api/src/migrations/Migration.kt b/api/src/migrations/Migration.kt index e661262..357465d 100644 --- a/api/src/migrations/Migration.kt +++ b/api/src/migrations/Migration.kt @@ -1,14 +1,14 @@ package be.vandewalleh.migrations -import be.vandewalleh.features.Feature import org.flywaydb.core.Flyway import org.kodein.di.Kodein +import org.kodein.di.KodeinAware import org.kodein.di.generic.instance import javax.sql.DataSource -class Migration(override val kodein: Kodein) : Feature(kodein) { +class Migration(override val kodein: Kodein) : KodeinAware { - override fun execute() { + fun migrate() { val dataSource by instance() val flyway = Flyway.configure() .dataSource(dataSource) diff --git a/api/src/routing/ChaptersController.kt b/api/src/routing/ChaptersController.kt new file mode 100644 index 0000000..76850b7 --- /dev/null +++ b/api/src/routing/ChaptersController.kt @@ -0,0 +1,14 @@ +package be.vandewalleh.routing + +import io.ktor.auth.authenticate +import io.ktor.routing.Routing +import io.ktor.routing.route +import org.kodein.di.Kodein + +fun Routing.chapters(kodein: Kodein) { + authenticate { + route("/notes/{noteTitle}/chapters/{chapterNumber}") { + + } + } +} diff --git a/api/src/routing/LoginController.kt b/api/src/routing/LoginController.kt new file mode 100644 index 0000000..d776de1 --- /dev/null +++ b/api/src/routing/LoginController.kt @@ -0,0 +1,39 @@ +package be.vandewalleh.routing + +import be.vandewalleh.auth.SimpleJWT +import be.vandewalleh.auth.UsernamePasswordCredential +import be.vandewalleh.services.UserService +import io.ktor.application.call +import io.ktor.http.HttpStatusCode +import io.ktor.request.receive +import io.ktor.response.respond +import io.ktor.routing.Routing +import io.ktor.routing.post +import io.ktor.routing.route +import org.kodein.di.Kodein +import org.kodein.di.generic.instance +import org.mindrot.jbcrypt.BCrypt + +fun Routing.login(kodein: Kodein) { + val simpleJwt by kodein.instance() + val userService by kodein.instance() + + data class TokenResponse(val token: String) + + route("/login"){ + post { + val credential = call.receive() + + val (email, password) = userService.getEmailAndPasswordFromUsername(credential.username) + ?: return@post call.respond(HttpStatusCode.Unauthorized) + + if (!BCrypt.checkpw(credential.password, password)) { + return@post call.respond(HttpStatusCode.Unauthorized) + } + + return@post call.respond(TokenResponse(simpleJwt.sign(email))) + } + } + + +} \ No newline at end of file diff --git a/api/src/routing/NotesController.kt b/api/src/routing/NotesController.kt new file mode 100644 index 0000000..f4108ea --- /dev/null +++ b/api/src/routing/NotesController.kt @@ -0,0 +1,23 @@ +package be.vandewalleh.routing + +import be.vandewalleh.extensions.userId +import be.vandewalleh.services.NotesService +import io.ktor.application.call +import io.ktor.auth.authenticate +import io.ktor.response.respond +import io.ktor.routing.Routing +import io.ktor.routing.get +import org.kodein.di.Kodein +import org.kodein.di.generic.instance + +fun Routing.notes(kodein: Kodein) { + val notesService by kodein.instance() + + authenticate { + get("/notes") { + val userId = call.userId() + val notes = notesService.getNotes(userId) + call.respond(notes) + } + } +} diff --git a/api/src/routing/RegisterController.kt b/api/src/routing/RegisterController.kt new file mode 100644 index 0000000..8f32ae0 --- /dev/null +++ b/api/src/routing/RegisterController.kt @@ -0,0 +1,33 @@ +package be.vandewalleh.routing + +import be.vandewalleh.extensions.respondStatus +import be.vandewalleh.services.UserRegistrationDto +import be.vandewalleh.services.UserService +import io.ktor.application.call +import io.ktor.http.HttpStatusCode +import io.ktor.request.receive +import io.ktor.response.respond +import io.ktor.routing.Routing +import io.ktor.routing.post +import org.kodein.di.Kodein +import org.kodein.di.generic.instance +import org.mindrot.jbcrypt.BCrypt + +fun Routing.register(kodein: Kodein) { + val userService by kodein.instance() + + post("/register") { + val user = call.receive() + + if (userService.userExists(user.username, user.email)) + return@post call.respond(HttpStatusCode.Conflict) + + val hashedPassword = BCrypt.hashpw(user.password, BCrypt.gensalt()) + + userService.createUser( + UserRegistrationDto(user.username, user.email, hashedPassword) + ) + + return@post call.respondStatus(HttpStatusCode.Created) + } +} \ No newline at end of file diff --git a/api/src/routing/Routes.kt b/api/src/routing/Routes.kt new file mode 100644 index 0000000..31e6401 --- /dev/null +++ b/api/src/routing/Routes.kt @@ -0,0 +1,12 @@ +package be.vandewalleh.routing + +import io.ktor.routing.Routing +import org.kodein.di.Kodein + +fun Routing.registerRoutes(kodein: Kodein) { + login(kodein) + register(kodein) + notes(kodein) + title(kodein) + chapters(kodein) +} \ No newline at end of file diff --git a/api/src/routing/TitleController.kt b/api/src/routing/TitleController.kt new file mode 100644 index 0000000..4fb4e95 --- /dev/null +++ b/api/src/routing/TitleController.kt @@ -0,0 +1,64 @@ +package be.vandewalleh.routing + +import be.vandewalleh.extensions.* +import be.vandewalleh.services.NotesService +import io.ktor.application.call +import io.ktor.auth.authenticate +import io.ktor.http.HttpStatusCode +import io.ktor.response.respond +import io.ktor.routing.* +import org.kodein.di.Kodein +import org.kodein.di.generic.instance + +fun Routing.title(kodein: Kodein) { + val notesService by kodein.instance() + + authenticate { + route("/notes/{noteTitle}") { + post { + val userId = call.userId() + val title = call.parameters.noteTitle() + val tags = call.receiveTags() + val noteId = call.parameters.noteId(userId) + + if (noteId != null) { + return@post call.respondStatus(HttpStatusCode.Conflict) + } + + notesService.createNote(userId, title, tags) + call.respondStatus(HttpStatusCode.Created) + } + + get { + val userId = call.userId() + val noteId = call.parameters.noteId(userId) + ?: return@get call.respondStatus(HttpStatusCode.NotFound) + + val response = notesService.getTagsAndChapters(noteId) + call.respond(response) + } + + patch { + val notePatch = call.receiveNotePatch() + if (notePatch.tags == null && notePatch.title == null) + return@patch call.respondStatus(HttpStatusCode.BadRequest) + + val userId = call.userId() + val noteId = call.parameters.noteId(userId) + ?: return@patch call.respondStatus(HttpStatusCode.NotFound) + + notesService.updateNote(noteId, notePatch.tags, notePatch.title) + call.respondStatus(HttpStatusCode.OK) + } + + delete { + val userId = call.userId() + val noteId = call.parameters.noteId(userId) + ?: return@delete call.respondStatus(HttpStatusCode.NotFound) + + notesService.deleteNote(noteId) + call.respondStatus(HttpStatusCode.OK) + } + } + } +} diff --git a/api/src/services/NotesService.kt b/api/src/services/NotesService.kt new file mode 100644 index 0000000..45ef13e --- /dev/null +++ b/api/src/services/NotesService.kt @@ -0,0 +1,120 @@ +package be.vandewalleh.services + +import be.vandewalleh.tables.Chapters +import be.vandewalleh.tables.Notes +import be.vandewalleh.tables.Tags +import me.liuwj.ktorm.database.Database +import me.liuwj.ktorm.dsl.* +import org.kodein.di.Kodein +import org.kodein.di.KodeinAware +import org.kodein.di.generic.instance +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +/** + * service to handle database queries at the Notes level. + */ +class NotesService(override val kodein: Kodein) : KodeinAware { + private val db by instance() + + /** + * returns a list of [NotesDTO] associated with the userId + */ + fun getNotes(userId: Int): List = db.from(Notes) + .select(Notes.id, Notes.title, Notes.updatedAt) + .where { Notes.userId eq userId } + .orderBy(Notes.updatedAt.desc()) + .map { row -> + Notes.createEntity(row) + } + .toList() + .map { note -> + val tags = db.from(Tags) + .select(Tags.name) + .where { Tags.noteId eq note.id } + .map { it[Tags.name]!! } + .toList() + + val updatedAt = DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(note.updatedAt) + + NotesDTO(note.title, tags, updatedAt) + } + + fun getNoteIdFromUserIdAndTitle(userId: Int, noteTitle: String): Int? = db.from(Notes) + .select(Notes.id) + .where { Notes.userId eq userId and (Notes.title eq noteTitle) } + .limit(0, 1) + .map { it[Notes.id]!! } + .firstOrNull() + + fun createNote(userId: Int, title: String, tags: List) { + db.useTransaction { + val noteId = db.insertAndGenerateKey(Notes) { + it.title to title + it.userId to userId + it.updatedAt to LocalDateTime.now() + } + + tags.forEach { tagName -> + db.insert(Tags) { + it.name to tagName + it.noteId to noteId + } + } + } + } + + fun getTagsAndChapters(noteId: Int): TagsChaptersDTO { + val tags = db.from(Tags) + .select(Tags.name) + .where { Tags.noteId eq noteId } + .map { it[Tags.name]!! } + .toList() + + val chapters = db.from(Chapters) + .select(Chapters.title, Chapters.content) + .where { Chapters.noteId eq noteId } + .orderBy(Chapters.number.asc()) + .map { ChaptersDTO(it[Chapters.title]!!, it[Chapters.content]!!) } + .toList() + + return TagsChaptersDTO(tags, chapters) + } + + fun updateNote(noteId: Int, tags: List?, title: String?): Unit = + db.useTransaction { + if (title != null) { + db.update(Notes) { + it.title to title + it.updatedAt to LocalDateTime.now() + where { it.id eq noteId } + } + } + + if (tags != null) { + // delete all tags + db.delete(Tags) { + it.noteId eq noteId + } + + // put new ones + tags.forEach { tagName -> + db.insert(Tags) { + it.name to tagName + it.noteId to noteId + } + } + } + } + + fun deleteNote(noteId: Int): Unit = + db.useTransaction { + db.delete(Tags) { it.noteId eq noteId } + db.delete(Chapters) { it.noteId eq noteId } + db.delete(Notes) { it.id eq noteId } + } +} + +data class ChaptersDTO(val title: String, val content: String) +data class TagsChaptersDTO(val tags: List, val chapters: List) +data class NotesDTO(val title: String, val tags: List, val updatedAt: String) \ No newline at end of file diff --git a/api/src/services/Services.kt b/api/src/services/Services.kt new file mode 100644 index 0000000..6db6891 --- /dev/null +++ b/api/src/services/Services.kt @@ -0,0 +1,14 @@ +package be.vandewalleh.services + +import org.kodein.di.Kodein +import org.kodein.di.generic.bind +import org.kodein.di.generic.singleton + + +/** + * [Kodein] controller module containing the app services + */ +val serviceModule = Kodein.Module(name = "Services") { + bind() with singleton { NotesService(this.kodein) } + bind() with singleton { UserService(this.kodein) } +} \ No newline at end of file diff --git a/api/src/services/UserService.kt b/api/src/services/UserService.kt new file mode 100644 index 0000000..67887d2 --- /dev/null +++ b/api/src/services/UserService.kt @@ -0,0 +1,71 @@ +package be.vandewalleh.services + +import be.vandewalleh.entities.User +import be.vandewalleh.tables.Users +import me.liuwj.ktorm.database.Database +import me.liuwj.ktorm.dsl.* +import me.liuwj.ktorm.entity.add +import me.liuwj.ktorm.entity.sequenceOf +import org.kodein.di.Kodein +import org.kodein.di.KodeinAware +import org.kodein.di.generic.instance +import java.time.LocalDateTime + +/** + * service to handle database queries for users. + */ +class UserService(override val kodein: Kodein) : KodeinAware { + private val db by instance() + + /** + * returns a user ID if present or null + */ + fun getUserId(userEmail: String): Int? { + return db.from(Users) + .select(Users.id) + .where { Users.email eq userEmail } + .limit(0, 1) + .map { it[Users.id] } + .firstOrNull() + } + + /** + * returns a user email and password from it's email if found or null + */ + fun getEmailAndPasswordFromUsername(username: String): Pair? { + return db.from(Users) + .select(Users.email, Users.password) + .where { Users.username eq username } + .limit(0, 1) + .map { row -> row[Users.email]!! to row[Users.password]!! } + .firstOrNull() + } + + fun userExists(username: String, email: String): Boolean { + return db.from(Users) + .select(Users.id) + .where { (Users.username eq username) or (Users.email eq email) } + .limit(0, 1) + .firstOrNull() != null + } + + /** + * create a new user + * password should already be hashed + */ + fun createUser(user: UserRegistrationDto) { + db.useTransaction { + val newUser = User { + this.username = user.username + this.email = user.email + this.password = user.password + this.createdAt = LocalDateTime.now() + } + + db.sequenceOf(Users).add(newUser) + } + } + +} + +data class UserRegistrationDto(val username: String, val email: String, val password: String) \ No newline at end of file diff --git a/public/index.html b/public/index.html index c840ea6..1e970fc 100644 --- a/public/index.html +++ b/public/index.html @@ -36,7 +36,8 @@ "type": "string" } } -}Register a new userPOST/registerAuthenticate user ¶Authenticate one user to access protected routes. +}Register a new userPOST/registerAuthenticate user ¶Authenticate + one user to access protected routing. POST http://localhost:5000/loginRequestsexample 1HeadersContent-Type: application/jsonBody{ "username": "babar", "password": "tortue"
/register
Authenticate one user to access protected routes.
Authenticate + one user to access protected routing.
Content-Type: application/json
{ "username": "babar", "password": "tortue"