Merge branch 'refactor/services'
This commit is contained in:
commit
ff01ab2af3
@ -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]
|
||||
|
||||
|
||||
@ -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");
|
||||
});
|
||||
%}
|
||||
|
||||
@ -71,11 +71,6 @@
|
||||
<artifactId>ktor-server-core</artifactId>
|
||||
<version>${ktor_version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.ktor</groupId>
|
||||
<artifactId>ktor-locations</artifactId>
|
||||
<version>${ktor_version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.ktor</groupId>
|
||||
<artifactId>ktor-jackson</artifactId>
|
||||
|
||||
@ -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<Feature>() with singleton { Migration(this.kodein) }
|
||||
bind<Migration>() with singleton { Migration(this.kodein) }
|
||||
bind<Database>() with singleton { Database.Companion.connect(this.instance<DataSource>()) }
|
||||
}
|
||||
|
||||
@ -38,15 +43,21 @@ fun Application.module() {
|
||||
|
||||
log.debug(kodein.container.tree.bindings.description())
|
||||
|
||||
// TODO, clean this (migration)
|
||||
val feature by kodein.instance<Feature>()
|
||||
feature.execute()
|
||||
|
||||
val controllers by kodein.instance<Set<KodeinController>>()
|
||||
val migration by kodein.instance<Migration>()
|
||||
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<Route> {
|
||||
return listOf(root) + root.children.flatMap { allRoutes(it) }
|
||||
}
|
||||
@ -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<SimpleJWT>()
|
||||
verifier(simpleJwt.verifier)
|
||||
validate {
|
||||
UserIdPrincipal(it.payload.getClaim("name").asString())
|
||||
|
||||
@ -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<UserIdPrincipal>()!!.name
|
||||
|
||||
override fun Routing.registerRoutes() {
|
||||
authenticate {
|
||||
route(path) {
|
||||
route()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<KodeinController>()
|
||||
|
||||
bind<KodeinController>().inSet() with singleton { UserController(this.kodein) }
|
||||
bind<KodeinController>().inSet() with singleton { HealthCheckController(this.kodein) }
|
||||
bind<KodeinController>().inSet() with singleton { NotesController(this.kodein) }
|
||||
bind<KodeinController>().inSet() with singleton { NotesTitleController(this.kodein) }
|
||||
}
|
||||
@ -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<Routes.Ping> {
|
||||
call.respondText("pong")
|
||||
}
|
||||
}
|
||||
|
||||
object Routes {
|
||||
@Location("/ping")
|
||||
class Ping
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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<Database>()
|
||||
|
||||
private class ResponseItem(val title: String, val tags: List<String>, 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<ResponseItem>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<Database>()
|
||||
|
||||
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<String>)
|
||||
|
||||
private class ChapterDto(val title: String, val content: String)
|
||||
private class GetResponseBody(val tags: List<String>, val chapters: List<ChapterDto>)
|
||||
|
||||
private class PatchRequestBody(val title: String? = null, val tags: List<String>? = null)
|
||||
|
||||
override val route: Route.() -> Unit = {
|
||||
post {
|
||||
val title = call.noteTitle() ?: error("")
|
||||
val tags = call.receive<PostRequestBody>().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<PatchRequestBody>()
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<SimpleJWT>()
|
||||
private val db by instance<Database>()
|
||||
|
||||
override fun Routing.registerRoutes() {
|
||||
|
||||
|
||||
post<Routes.Login> {
|
||||
data class Response(val token: String)
|
||||
|
||||
val credential = call.receive<UsernamePasswordCredential>()
|
||||
|
||||
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<Routes.Register> {
|
||||
data class Response(val message: String)
|
||||
|
||||
val user = call.receive<RegisterInfo>()
|
||||
|
||||
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)
|
||||
@ -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")
|
||||
}
|
||||
35
api/src/extensions/ApplicationCallExtensions.kt
Normal file
35
api/src/extensions/ApplicationCallExtensions.kt
Normal file
@ -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<UserService>()
|
||||
|
||||
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<UserIdPrincipal>()!!.name
|
||||
return userService.getUserId(email)!!
|
||||
}
|
||||
|
||||
private class Tags(val tags: List<String>)
|
||||
|
||||
suspend fun ApplicationCall.receiveTags(): List<String> {
|
||||
return receive<Tags>().tags
|
||||
}
|
||||
|
||||
data class NotePatch(val tags: List<String>?, val title: String?)
|
||||
|
||||
suspend fun ApplicationCall.receiveNotePatch() = receive<NotePatch>()
|
||||
22
api/src/extensions/ParametersExtensions.kt
Normal file
22
api/src/extensions/ParametersExtensions.kt
Normal file
@ -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<NotesService>()
|
||||
|
||||
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)
|
||||
}
|
||||
17
api/src/features/ErrorFeature.kt
Normal file
17
api/src/features/ErrorFeature.kt
Normal file
@ -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<IOException> { _ ->
|
||||
call.respond(HttpStatusCode.BadRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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<DataSource>()
|
||||
val flyway = Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
|
||||
14
api/src/routing/ChaptersController.kt
Normal file
14
api/src/routing/ChaptersController.kt
Normal file
@ -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}") {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
39
api/src/routing/LoginController.kt
Normal file
39
api/src/routing/LoginController.kt
Normal file
@ -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<SimpleJWT>()
|
||||
val userService by kodein.instance<UserService>()
|
||||
|
||||
data class TokenResponse(val token: String)
|
||||
|
||||
route("/login"){
|
||||
post {
|
||||
val credential = call.receive<UsernamePasswordCredential>()
|
||||
|
||||
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)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
23
api/src/routing/NotesController.kt
Normal file
23
api/src/routing/NotesController.kt
Normal file
@ -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<NotesService>()
|
||||
|
||||
authenticate {
|
||||
get("/notes") {
|
||||
val userId = call.userId()
|
||||
val notes = notesService.getNotes(userId)
|
||||
call.respond(notes)
|
||||
}
|
||||
}
|
||||
}
|
||||
33
api/src/routing/RegisterController.kt
Normal file
33
api/src/routing/RegisterController.kt
Normal file
@ -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<UserService>()
|
||||
|
||||
post("/register") {
|
||||
val user = call.receive<UserRegistrationDto>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
12
api/src/routing/Routes.kt
Normal file
12
api/src/routing/Routes.kt
Normal file
@ -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)
|
||||
}
|
||||
64
api/src/routing/TitleController.kt
Normal file
64
api/src/routing/TitleController.kt
Normal file
@ -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<NotesService>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
120
api/src/services/NotesService.kt
Normal file
120
api/src/services/NotesService.kt
Normal file
@ -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<Database>()
|
||||
|
||||
/**
|
||||
* returns a list of [NotesDTO] associated with the userId
|
||||
*/
|
||||
fun getNotes(userId: Int): List<NotesDTO> = 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<String>) {
|
||||
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<String>?, 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<String>, val chapters: List<ChaptersDTO>)
|
||||
data class NotesDTO(val title: String, val tags: List<String>, val updatedAt: String)
|
||||
14
api/src/services/Services.kt
Normal file
14
api/src/services/Services.kt
Normal file
@ -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<NotesService>() with singleton { NotesService(this.kodein) }
|
||||
bind<UserService>() with singleton { UserService(this.kodein) }
|
||||
}
|
||||
71
api/src/services/UserService.kt
Normal file
71
api/src/services/UserService.kt
Normal file
@ -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<Database>()
|
||||
|
||||
/**
|
||||
* 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<String, String>? {
|
||||
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)
|
||||
@ -36,7 +36,8 @@
|
||||
"<span class="hljs-attribute">type</span>": <span class="hljs-value"><span class="hljs-string">"string"</span>
|
||||
</span>}
|
||||
</span>}
|
||||
</span>}</code></pre><div style="height: 1px;"></div></div></div></div></div></div></div></div><div class="middle"><div id="accounts-create-an-account-post" class="action post"><h4 class="action-heading"><div class="name">Register a new user</div><a href="#accounts-create-an-account-post" class="method post">POST</a><code class="uri">/register</code></h4></div></div><hr class="split"><div class="middle"><div id="accounts-authenticate-user" class="resource"><h3 class="resource-heading">Authenticate user <a href="#accounts-authenticate-user" class="permalink">¶</a></h3><p>Authenticate one user to access protected routes.</p>
|
||||
</span>}</code></pre><div style="height: 1px;"></div></div></div></div></div></div></div></div><div class="middle"><div id="accounts-create-an-account-post" class="action post"><h4 class="action-heading"><div class="name">Register a new user</div><a href="#accounts-create-an-account-post" class="method post">POST</a><code class="uri">/register</code></h4></div></div><hr class="split"><div class="middle"><div id="accounts-authenticate-user" class="resource"><h3 class="resource-heading">Authenticate user <a href="#accounts-authenticate-user" class="permalink">¶</a></h3><p>Authenticate
|
||||
one user to access protected routing.</p>
|
||||
</div></div><div class="right"><div class="definition"><span class="method post">POST</span> <span class="uri"><span class="hostname">http://localhost:5000</span>/login</span></div><div class="tabs"><div class="example-names"><span>Requests</span><span class="tab-button">example 1</span></div><div class="tab"><div><div class="inner"><h5>Headers</h5><pre><code><span class="hljs-attribute">Content-Type</span>: <span class="hljs-string">application/json</span></code></pre><div style="height: 1px;"></div><h5>Body</h5><pre><code>{
|
||||
"<span class="hljs-attribute">username</span>": <span class="hljs-value"><span class="hljs-string">"babar"</span></span>,
|
||||
"<span class="hljs-attribute">password</span>": <span class="hljs-value"><span class="hljs-string">"tortue"</span>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user