diff --git a/api/http/test.http b/api/http/test.http deleted file mode 100644 index 6675bf1..0000000 --- a/api/http/test.http +++ /dev/null @@ -1,77 +0,0 @@ -# Register a new user -POST http://localhost:8081/user/login -Content-Type: application/json - -{ - "username": "{{username}}", - "password": "{{password}}" -} - -> {% -client.global.set("token", response.body.token); -client.global.set("refreshToken", response.body.refreshToken); -client.test("Request executed successfully", function() { - client.assert(response.status === 200, "Response status is not 200"); -}); -%} - -### Refresh token -POST http://localhost:8081/user/refresh_token -Content-Type: application/json - -{ - "refreshToken": "{{refreshToken}}" -} - -> {% -client.test("Request executed successfully", function() { - client.global.set("token", response.body.token); - client.assert(response.status === 200, "Response status is not 200"); -}); -%} - -### Get notes -GET http://localhost:8081/notes -Authorization: Bearer {{token}} - -> {% -client.global.set("uuid", response.body[0].uuid); -client.test("Request executed successfully", function() { - client.assert(response.status === 200, "Response status is not 200"); -}); -%} - -### Create note -POST http://localhost:8081/notes -Authorization: Bearer {{token}} -Content-Type: application/json - -{ - "title": "test", - "tags": [ - "Some", - "Tags" - ], - "chapters": [ - { - "title": "Chapter 1", - "content": "# This is some content" - } - ] -} - -> {% -client.test("Request executed successfully", function() { - client.assert(response.status === 201, "Response status is not 201"); -}); -%} - -### Get a note -GET http://localhost:8081/notes/{{uuid}} -Authorization: Bearer {{token}} - -> {% -client.test("Request executed successfully", function() { - client.assert(response.status === 200, "Response status is not 200"); -}); -%} \ No newline at end of file diff --git a/api/resources/application.dev.yaml b/api/resources/application.dev.yaml index 58459ea..6f557b4 100644 --- a/api/resources/application.dev.yaml +++ b/api/resources/application.dev.yaml @@ -7,7 +7,7 @@ database: server: host: 127.0.0.1 - port: 8081 + port: ${PORT:-8081} cors: true jwt: diff --git a/api/resources/logback.xml b/api/resources/logback.xml index 032fd8d..ad8c3e6 100644 --- a/api/resources/logback.xml +++ b/api/resources/logback.xml @@ -1,10 +1,12 @@ + true - %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + %cyan(%d{YYYY-MM-dd HH:mm:ss.SSS}) [%thread] %highlight(%-5level) %magenta(%logger{36}) - %msg%n + - + diff --git a/api/src/NotesApplication.kt b/api/src/NotesApplication.kt index 474ec83..fdbe62b 100644 --- a/api/src/NotesApplication.kt +++ b/api/src/NotesApplication.kt @@ -3,11 +3,16 @@ package be.vandewalleh import be.vandewalleh.features.Config import be.vandewalleh.features.loadFeatures import be.vandewalleh.migrations.Migration -import be.vandewalleh.routing.registerRoutes +import be.vandewalleh.routing.noteRoutes +import be.vandewalleh.routing.tagsRoute +import be.vandewalleh.routing.userRoutes +import com.sksamuel.hoplite.fp.valid import io.ktor.application.* +import io.ktor.auth.* import io.ktor.routing.* import io.ktor.server.engine.* import io.ktor.server.netty.* +import me.liuwj.ktorm.database.* import org.kodein.di.Kodein import org.kodein.di.description import org.kodein.di.generic.instance @@ -50,7 +55,17 @@ fun Application.module(kodein: Kodein) { loadFeatures(kodein) routing { - registerRoutes(kodein) + route("/user") { + userRoutes(kodein) + } + authenticate { + route("/notes") { + noteRoutes(kodein) + } + route("/tags") { + tagsRoute(kodein) + } + } } } diff --git a/api/src/extensions/ApplicationCallExtensions.kt b/api/src/extensions/ApplicationCallExtensions.kt index 08eecd8..8727dc1 100644 --- a/api/src/extensions/ApplicationCallExtensions.kt +++ b/api/src/extensions/ApplicationCallExtensions.kt @@ -7,10 +7,10 @@ import io.ktor.http.* import io.ktor.response.* suspend fun ApplicationCall.respondStatus(status: HttpStatusCode) { - respond(status, """{"msg": "${status.description}"}""") + respond(status, """{"status": "${status.description}"}""") } /** * @return the userId for the currently authenticated user */ -fun ApplicationCall.userId() = principal()!!.id +fun ApplicationCall.authenticatedUserId() = principal()!!.id diff --git a/api/src/extensions/ParametersExtensions.kt b/api/src/extensions/ParametersExtensions.kt deleted file mode 100644 index 10f817b..0000000 --- a/api/src/extensions/ParametersExtensions.kt +++ /dev/null @@ -1,8 +0,0 @@ -package be.vandewalleh.extensions - -import io.ktor.http.* -import java.util.* - -fun Parameters.noteUuid(): UUID { - return UUID.fromString(this["noteUuid"]) -} diff --git a/api/src/features/ConfigurationFeature.kt b/api/src/features/ConfigurationFeature.kt index a46eb68..ea3fb7f 100644 --- a/api/src/features/ConfigurationFeature.kt +++ b/api/src/features/ConfigurationFeature.kt @@ -60,6 +60,7 @@ private fun configureDatasource(kodein: Kodein): DataSource { jdbcUrl = "jdbc:mariadb://$host:$port/$name" username = dbConfig.username password = dbConfig.password.value + connectionTimeout = 3000 // 3 seconds } return HikariDataSource(hikariConfig) diff --git a/api/src/features/ErrorFeature.kt b/api/src/features/ErrorFeature.kt index adde009..0462e9d 100644 --- a/api/src/features/ErrorFeature.kt +++ b/api/src/features/ErrorFeature.kt @@ -1,12 +1,11 @@ package be.vandewalleh.features -import am.ik.yavi.core.ViolationDetail -import be.vandewalleh.validation.ValidationException import io.ktor.application.* import io.ktor.features.* import io.ktor.http.* import io.ktor.response.* import io.ktor.utils.io.errors.* +import java.sql.SQLTransientConnectionException fun Application.handleErrors() { install(StatusPages) { @@ -17,12 +16,15 @@ fun Application.handleErrors() { call.respond(HttpStatusCode.BadRequest) } exception { - val error = ViolationError(it.details[0]) - call.respond(HttpStatusCode.BadRequest, error) + call.respond(HttpStatusCode.BadRequest, ErrorResponse(it.error)) + } + + exception { + val error = mapOf("error" to "It seems the server can't connect to the database") + call.respond(HttpStatusCode.InternalServerError, error) } } } -class ViolationError(detail: ViolationDetail) { - val msg = detail.defaultMessage -} +class ValidationException(val error: String) : RuntimeException() +class ErrorResponse(val error: String) diff --git a/api/src/routing/NoteRoutes.kt b/api/src/routing/NoteRoutes.kt new file mode 100644 index 0000000..c3d3e1f --- /dev/null +++ b/api/src/routing/NoteRoutes.kt @@ -0,0 +1,75 @@ +package be.vandewalleh.routing + +import be.vandewalleh.extensions.authenticatedUserId +import be.vandewalleh.extensions.respondStatus +import be.vandewalleh.features.ValidationException +import be.vandewalleh.services.NoteService +import be.vandewalleh.validation.noteValidator +import be.vandewalleh.validation.receiveValidated +import io.ktor.application.* +import io.ktor.http.* +import io.ktor.response.* +import io.ktor.routing.* +import org.kodein.di.Kodein +import org.kodein.di.generic.instance +import java.util.* + +fun Route.noteRoutes(kodein: Kodein) { + val noteService by kodein.instance() + + post { + val userId = call.authenticatedUserId() + val note = call.receiveValidated(noteValidator) + val createdNote = noteService.create(userId, note) + call.respond(HttpStatusCode.Created, createdNote) + } + + get { + val userId = call.authenticatedUserId() + val notes = noteService.findAll(userId) + call.respond(notes) + } + + route("/{uuid}") { + + fun ApplicationCall.userIdNoteIdPair(): Pair { + val userId = authenticatedUserId() + val uuid = parameters["uuid"] + val noteUuid = try { + UUID.fromString(uuid) + } catch (e: IllegalArgumentException) { + throw ValidationException("`$uuid` is not a valid UUID") + } + return userId to noteUuid + } + + get { + val (userId, noteUuid) = call.userIdNoteIdPair() + + val response = noteService.find(userId, noteUuid) + ?: return@get call.respondStatus(HttpStatusCode.NotFound) + call.respond(response) + } + + put { + val (userId, noteUuid) = call.userIdNoteIdPair() + + val note = call.receiveValidated(noteValidator) + note.uuid = noteUuid + + if (noteService.updateNote(userId, note)) + call.respondStatus(HttpStatusCode.OK) + else call.respondStatus(HttpStatusCode.NotFound) + } + + delete { + val (userId, noteUuid) = call.userIdNoteIdPair() + + if (noteService.delete(userId, noteUuid)) + call.respondStatus(HttpStatusCode.OK) + else + call.respondStatus(HttpStatusCode.NotFound) + } + + } +} diff --git a/api/src/routing/Routes.kt b/api/src/routing/Routes.kt deleted file mode 100644 index eeb6075..0000000 --- a/api/src/routing/Routes.kt +++ /dev/null @@ -1,17 +0,0 @@ -package be.vandewalleh.routing - -import be.vandewalleh.routing.notes.notes -import be.vandewalleh.routing.notes.tags -import be.vandewalleh.routing.notes.title -import be.vandewalleh.routing.user.auth -import be.vandewalleh.routing.user.user -import io.ktor.routing.* -import org.kodein.di.Kodein - -fun Routing.registerRoutes(kodein: Kodein) { - user(kodein) - auth(kodein) - notes(kodein) - title(kodein) - tags(kodein) -} diff --git a/api/src/routing/notes/TagController.kt b/api/src/routing/TagRoutes.kt similarity index 50% rename from api/src/routing/notes/TagController.kt rename to api/src/routing/TagRoutes.kt index ba8c13c..e721c51 100644 --- a/api/src/routing/notes/TagController.kt +++ b/api/src/routing/TagRoutes.kt @@ -1,20 +1,17 @@ -package be.vandewalleh.routing.notes +package be.vandewalleh.routing -import be.vandewalleh.extensions.userId +import be.vandewalleh.extensions.authenticatedUserId import be.vandewalleh.services.NoteService import io.ktor.application.* -import io.ktor.auth.* import io.ktor.response.* import io.ktor.routing.* import org.kodein.di.Kodein import org.kodein.di.generic.instance -fun Routing.tags(kodein: Kodein) { +fun Route.tagsRoute(kodein: Kodein) { val noteService by kodein.instance() - authenticate { - get("/tags") { - call.respond(noteService.getTags(call.userId())) - } + get { + call.respond(noteService.getTags(call.authenticatedUserId())) } } diff --git a/api/src/routing/user/AuthController.kt b/api/src/routing/UserRoutes.kt similarity index 71% rename from api/src/routing/user/AuthController.kt rename to api/src/routing/UserRoutes.kt index de6a308..bb2e1cf 100644 --- a/api/src/routing/user/AuthController.kt +++ b/api/src/routing/UserRoutes.kt @@ -1,11 +1,14 @@ -package be.vandewalleh.routing.user +package be.vandewalleh.routing import be.vandewalleh.auth.SimpleJWT import be.vandewalleh.auth.UserDbIdPrincipal import be.vandewalleh.auth.UsernamePasswordCredential import be.vandewalleh.extensions.respondStatus +import be.vandewalleh.extensions.authenticatedUserId import be.vandewalleh.features.PasswordHash import be.vandewalleh.services.UserService +import be.vandewalleh.validation.receiveValidated +import be.vandewalleh.validation.registerValidator import com.auth0.jwt.exceptions.JWTVerificationException import io.ktor.application.* import io.ktor.auth.* @@ -19,13 +22,25 @@ import org.kodein.di.generic.instance data class RefreshToken(val refreshToken: String) data class DualToken(val token: String, val refreshToken: String) -fun Routing.auth(kodein: Kodein) { +fun Route.userRoutes(kodein: Kodein) { val authSimpleJwt by kodein.instance("auth") val refreshSimpleJwt by kodein.instance("refresh") val userService by kodein.instance() val passwordHash by kodein.instance() - post("/user/login") { + post { + val user = call.receiveValidated(registerValidator) + + if (userService.exists(user.username)) + return@post call.respondStatus(HttpStatusCode.Conflict) + + val newUser = userService.create(user.username, user.password) + ?: return@post call.respondStatus(HttpStatusCode.Conflict) + + call.respond(HttpStatusCode.Created, newUser) + } + + post("/login") { val credential = call.receive() val user = userService.find(credential.username) @@ -42,7 +57,7 @@ fun Routing.auth(kodein: Kodein) { return@post call.respond(response) } - post("/user/refresh_token") { + post("/refresh_token") { val token = call.receive().refreshToken val id = try { @@ -63,12 +78,19 @@ fun Routing.auth(kodein: Kodein) { } authenticate { - get("/user/me") { + delete { + val userId = call.authenticatedUserId() + call.respondStatus( + if (userService.delete(userId)) HttpStatusCode.OK + else HttpStatusCode.NotFound + ) + } + + get("/me") { val id = call.principal()!!.id val info = userService.find(id) if (info != null) call.respond(mapOf("user" to info)) else call.respondStatus(HttpStatusCode.Unauthorized) } } - } diff --git a/api/src/routing/notes/NoteController.kt b/api/src/routing/notes/NoteController.kt deleted file mode 100644 index c02fc02..0000000 --- a/api/src/routing/notes/NoteController.kt +++ /dev/null @@ -1,53 +0,0 @@ -package be.vandewalleh.routing.notes - -import be.vandewalleh.entities.Note -import be.vandewalleh.extensions.noteUuid -import be.vandewalleh.extensions.respondStatus -import be.vandewalleh.extensions.userId -import be.vandewalleh.services.NoteService -import io.ktor.application.call -import io.ktor.auth.authenticate -import io.ktor.http.HttpStatusCode -import io.ktor.request.* -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 noteService by kodein.instance() - - authenticate { - route("/notes/{noteUuid}") { - get { - val userId = call.userId() - val noteUuid = call.parameters.noteUuid() - - val response = noteService.find(userId, noteUuid) - ?: return@get call.respondStatus(HttpStatusCode.NotFound) - call.respond(response) - } - - put { - val userId = call.userId() - val noteUuid = call.parameters.noteUuid() - val note = call.receive() - note.uuid = noteUuid - - if (noteService.updateNote(userId, note)) - call.respondStatus(HttpStatusCode.OK) - else call.respondStatus(HttpStatusCode.NotFound) - } - - delete { - val userId = call.userId() - val noteUuid = call.parameters.noteUuid() - - if (noteService.delete(userId, noteUuid)) - call.respondStatus(HttpStatusCode.OK) - else - call.respondStatus(HttpStatusCode.NotFound) - } - } - } -} diff --git a/api/src/routing/notes/NotesController.kt b/api/src/routing/notes/NotesController.kt deleted file mode 100644 index 330d22d..0000000 --- a/api/src/routing/notes/NotesController.kt +++ /dev/null @@ -1,32 +0,0 @@ -package be.vandewalleh.routing.notes - -import be.vandewalleh.entities.Note -import be.vandewalleh.extensions.userId -import be.vandewalleh.services.NoteService -import io.ktor.application.* -import io.ktor.auth.* -import io.ktor.http.* -import io.ktor.request.* -import io.ktor.response.* -import io.ktor.routing.* -import org.kodein.di.Kodein -import org.kodein.di.generic.instance - -fun Routing.notes(kodein: Kodein) { - val noteService by kodein.instance() - - authenticate { - get("/notes") { - val userId = call.userId() - val notes = noteService.findAll(userId) - call.respond(notes) - } - - post("/notes") { - val userId = call.userId() - val note = call.receive() - val createdNote = noteService.create(userId, note) - call.respond(HttpStatusCode.Created, createdNote) - } - } -} diff --git a/api/src/routing/user/UserController.kt b/api/src/routing/user/UserController.kt deleted file mode 100644 index c33fdf8..0000000 --- a/api/src/routing/user/UserController.kt +++ /dev/null @@ -1,43 +0,0 @@ -package be.vandewalleh.routing.user - -import be.vandewalleh.extensions.respondStatus -import be.vandewalleh.extensions.userId -import be.vandewalleh.services.UserService -import be.vandewalleh.validation.receiveValidated -import be.vandewalleh.validation.user.registerValidator -import io.ktor.application.* -import io.ktor.auth.* -import io.ktor.http.* -import io.ktor.response.* -import io.ktor.routing.* -import org.kodein.di.Kodein -import org.kodein.di.generic.instance - -fun Routing.user(kodein: Kodein) { - val userService by kodein.instance() - - route("/user") { - post { - val user = call.receiveValidated(registerValidator) - - if (userService.exists(user.username)) - return@post call.respondStatus(HttpStatusCode.Conflict) - - val newUser = userService.create(user.username, user.password) - ?: return@post call.respondStatus(HttpStatusCode.Conflict) - - call.respond(HttpStatusCode.Created, newUser) - } - - authenticate { - delete { - val status = if (userService.delete(call.userId())) - HttpStatusCode.OK - else - HttpStatusCode.NotFound - call.respondStatus(status) - } - } - - } -} diff --git a/api/src/services/NoteService.kt b/api/src/services/NoteService.kt index a6bae41..41e86d8 100644 --- a/api/src/services/NoteService.kt +++ b/api/src/services/NoteService.kt @@ -26,7 +26,7 @@ class NoteService(override val kodein: Kodein) : KodeinAware { suspend fun findAll(userId: Int): List { val notes = launchIo { db.sequenceOf(Notes, withReferences = false) - .filterColumns { it.columns - it.userId - it.content } + .filterColumns { listOf(it.uuid, it.title, it.updatedAt) } .filter { it.userId eq userId } .sortedByDescending { it.updatedAt } .toList() @@ -41,7 +41,7 @@ class NoteService(override val kodein: Kodein) : KodeinAware { .toList() } - val tagsByUuid = allTags.groupBy({ it.note.uuid }, { it.name }) + val tagsByUuid = allTags.groupByTo(HashMap(), { it.note.uuid }, { it.name }) notes.forEach { val tags = tagsByUuid[it.uuid] diff --git a/api/src/validation/user/UserValidation.kt b/api/src/validation/UserValidation.kt similarity index 91% rename from api/src/validation/user/UserValidation.kt rename to api/src/validation/UserValidation.kt index fb1cb47..af61d05 100644 --- a/api/src/validation/user/UserValidation.kt +++ b/api/src/validation/UserValidation.kt @@ -1,4 +1,4 @@ -package be.vandewalleh.validation.user +package be.vandewalleh.validation import am.ik.yavi.builder.ValidatorBuilder import am.ik.yavi.builder.konstraint diff --git a/api/src/validation/ValidationExtensions.kt b/api/src/validation/ValidationExtensions.kt index 1d00401..c98931a 100644 --- a/api/src/validation/ValidationExtensions.kt +++ b/api/src/validation/ValidationExtensions.kt @@ -1,14 +1,12 @@ package be.vandewalleh.validation import am.ik.yavi.core.Validator -import am.ik.yavi.core.ViolationDetail +import be.vandewalleh.features.ValidationException import io.ktor.application.* import io.ktor.request.* suspend inline fun ApplicationCall.receiveValidated(validator: Validator): T { val value: T = receive() - validator.validate(value).throwIfInvalid { ValidationException(it.details()) } + validator.validate(value).throwIfInvalid { ValidationException(it.details()[0].defaultMessage) } return value } - -data class ValidationException(val details: List) : RuntimeException() diff --git a/api/test/unit/validation/RegisterValidationTest.kt b/api/test/unit/validation/RegisterValidationTest.kt index 0fac7da..767e246 100644 --- a/api/test/unit/validation/RegisterValidationTest.kt +++ b/api/test/unit/validation/RegisterValidationTest.kt @@ -1,7 +1,7 @@ package unit.validation import be.vandewalleh.entities.User -import be.vandewalleh.validation.user.registerValidator +import be.vandewalleh.validation.registerValidator import org.amshove.kluent.* import org.junit.jupiter.api.* import utils.firstInvalid