From 01bbb7ca9f8d4f55247ed943b54153ce36cbb08e Mon Sep 17 00:00:00 2001 From: Jurjen Vorhauer Date: Fri, 16 Feb 2024 17:45:36 +0100 Subject: [PATCH] ids are string now: JavaScript can't handle the Java Long. --- src/main/kotlin/blog/Main.kt | 2 +- src/main/kotlin/blog/model/Note.kt | 39 ++++--- src/main/kotlin/blog/model/State.kt | 32 ++--- src/main/kotlin/blog/model/Tag.kt | 8 +- src/main/kotlin/blog/model/Task.kt | 48 ++++---- src/main/kotlin/blog/model/User.kt | 56 +++++---- src/main/kotlin/blog/model/model.kt | 11 +- src/main/kotlin/blog/module/Authentication.kt | 8 +- src/main/kotlin/blog/module/Status.kt | 8 +- src/main/kotlin/blog/read/Reader.kt | 26 ++--- src/main/kotlin/blog/write/Processor.kt | 2 +- src/test/kotlin/blog/ApiTests.kt | 110 ++++++++++-------- src/test/kotlin/blog/MainTest.kt | 2 +- src/test/kotlin/blog/model/NoteTests.kt | 2 +- src/test/kotlin/blog/model/TaskTests.kt | 5 +- src/test/kotlin/blog/model/UserTests.kt | 4 +- src/test/kotlin/blog/write/ProcessorTests.kt | 18 +-- src/test/requests/users-v2.http | 6 +- 18 files changed, 208 insertions(+), 179 deletions(-) diff --git a/src/main/kotlin/blog/Main.kt b/src/main/kotlin/blog/Main.kt index a20116f..9cbb158 100644 --- a/src/main/kotlin/blog/Main.kt +++ b/src/main/kotlin/blog/Main.kt @@ -25,7 +25,7 @@ import blog.read.Reader import blog.read.info import blog.write.Processor -const val pid: String = "27" +const val pid: String = "28" object Main { private val kfg: Konfig = ConfigFactory.load("application.conf").extract("konomas") diff --git a/src/main/kotlin/blog/model/Note.kt b/src/main/kotlin/blog/model/Note.kt index 815eb7e..fe07f97 100644 --- a/src/main/kotlin/blog/model/Note.kt +++ b/src/main/kotlin/blog/model/Note.kt @@ -8,6 +8,7 @@ import java.time.LocalDateTime import java.time.ZoneId import akka.actor.typed.Scheduler import akka.actor.typed.javadsl.AskPattern +import io.hypersistence.tsid.TSID import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.auth.* @@ -18,55 +19,55 @@ import kotlinx.coroutines.future.await import blog.read.Reader data class CreateNoteRequest(val title: String, val body: String) { - fun toCommand(user: Long, replyTo: ActorRef>) = CreateNote(user, title, body, replyTo) + fun toCommand(user: String, replyTo: ActorRef>) = CreateNote(user, title, body, replyTo) } -data class UpdateNoteRequest(val id: Long, val title: String?, val body: String?) { - fun toCommand(user: Long, rt: ActorRef>) = UpdateNote(user, id, title, body, rt) +data class UpdateNoteRequest(val id: String, val title: String?, val body: String?) { + fun toCommand(user: String, rt: ActorRef>) = UpdateNote(user, id, title, body, rt) } data class CreateNote( - val user: Long, + val user: String, val title: String, val body: String, val replyTo: ActorRef>, - val id: Long = nextId() + val id: String = nextId() ) : Command { fun toEvent() = NoteCreated(id, user, Encode.forHtml(title), Encode.forHtml(body)) } -data class UpdateNote(val user: Long, val id: Long, val title: String?, val body: String?, val replyTo: ActorRef>): Command { +data class UpdateNote(val user: String, val id: String, val title: String?, val body: String?, val replyTo: ActorRef>): Command { fun toEvent() = NoteUpdated(id, user, title, body) } -data class DeleteNote(val id: Long, val rt: ActorRef>): Command { +data class DeleteNote(val id: String, val rt: ActorRef>): Command { fun toEvent() = NoteDeleted(id) } -data class NoteCreated(val id: Long, val user: Long, val title: String, val body: String) : Event { +data class NoteCreated(val id: String, val user: String, val title: String, val body: String) : Event { fun toEntity() = Note(id, user, title, slugify(title), body) fun toResponse() = this.toEntity().toResponse() } -data class NoteUpdated(val id: Long, val user: Long, val title: String?, val body: String?): Event +data class NoteUpdated(val id: String, val user: String, val title: String?, val body: String?): Event -data class NoteDeleted(val id: Long): Event +data class NoteDeleted(val id: String): Event data class Note( - override val id: Long, - val user: Long, + override val id: String, + val user: String, val title: String, val slug: String, val body: String ): Entity { - constructor(id: Long, user: Long, title: String, body: String): this(id, user, title, slugify(title), body) + constructor(id: String, user: String, title: String, body: String): this(id, user, title, slugify(title), body) fun update(nu: NoteUpdated): Note = this.copy(title = nu.title ?: this.title, body = nu.body ?: this.body) - fun toResponse() = NoteResponse(id, user, DTF.format(LocalDateTime.ofInstant(id.toTSID().instant, ZoneId.of("CET"))), title, body) + fun toResponse() = NoteResponse(id, user, DTF.format(LocalDateTime.ofInstant(TSID.from(id).instant, ZoneId.of("CET"))), title, body) } data class NoteResponse( - val id: Long, - val user: Long, + val id: String, + val user: String, val created: String, val title: String, val body: String @@ -93,12 +94,12 @@ fun Route.notesRoute(processor: ActorRef, reader: Reader, scheduler: Sc call.respond(reader.allNotes(rows, start).map { it.toResponse() }) } get("{id?}") { - val id = call.parameters["id"]?.toLong() ?: return@get call.respond(HttpStatusCode.NotFound, "note id not specified") - val note: Note = reader.find(id) ?: return@get call.respond(HttpStatusCode.NotFound, "note not found for $id") + val id = call.parameters["id"] ?: return@get call.respond(HttpStatusCode.NotFound, "note id not specified") + val note = reader.findNote(id) ?: return@get call.respond(HttpStatusCode.NotFound, "note not found for $id") call.respond(note.toResponse()) } delete("{id?}") { - val id = call.parameters["id"]?.toLong() ?: return@delete call.respond(HttpStatusCode.NotFound, "note id not specified") + val id = call.parameters["id"] ?: return@delete call.respond(HttpStatusCode.NotFound, "note id not specified") reader.findNote(id) ?: return@delete call.respond(HttpStatusCode.NotFound, "note not found for $id") AskPattern.ask(processor, { rt -> DeleteNote(id, rt) }, timeout, scheduler).await().let { when { diff --git a/src/main/kotlin/blog/model/State.kt b/src/main/kotlin/blog/model/State.kt index 8584043..910ee73 100644 --- a/src/main/kotlin/blog/model/State.kt +++ b/src/main/kotlin/blog/model/State.kt @@ -1,30 +1,30 @@ package blog.model data class State( - private val users: Map = mapOf(), - private val notes: Map = mapOf(), - private val tasks: Map = mapOf(), - private val tags : Map = mapOf(), + private val users: Map = mapOf(), + private val notes: Map = mapOf(), + private val tasks: Map = mapOf(), + private val tags : Map = mapOf(), private val recovered: Boolean = false ) { fun save(u: User) : State = this.copy(users = this.users.minus(u.id).plus(u.id to u)) - fun findUser(id: Long) : User? = users[id] + fun findUser(id: String) : User? = users[id] fun findUserByEmail(email: String): User? = users.values.find { it.email == email } fun allUsers() : List = users.values.toList() fun userCount() : Int = users.size - fun deleteUser(id: Long) : State = this.copy(users = this.users.minus(id)) + fun deleteUser(id: String) : State = this.copy(users = this.users.minus(id)) - fun save(n: Note) : State = this.copy(notes = this.notes.minus(n.id).plus(n.id to n)) - fun findNote(id: Long) : Note? = notes[id] - fun findNotesForUser(id: Long): List = notes.values.filter { it.user == id } - fun noteCount() : Int = notes.size - fun deleteNote(id: Long) : State = this.copy(notes = this.notes.minus(id)) + fun save(n: Note) : State = this.copy(notes = this.notes.minus(n.id).plus(n.id to n)) + fun findNote(id: String) : Note? = notes[id] + fun findNotesForUser(id: String): List = notes.values.filter { it.user == id } + fun noteCount() : Int = notes.size + fun deleteNote(id: String) : State = this.copy(notes = this.notes.minus(id)) - fun save(t: Task) : State = this.copy(tasks = this.tasks.minus(t.id).plus(t.id to t)) - fun findTask(id: Long) : Task? = tasks[id] - fun findTasksForUser(id: Long): List = tasks.values.filter { it.user == id } - fun taskCount() : Int = tasks.size - fun deleteTask(id: Long) : State = this.copy(tasks = this.tasks.minus(id)) + fun save(t: Task) : State = this.copy(tasks = this.tasks.minus(t.id).plus(t.id to t)) + fun findTask(id: String) : Task? = tasks[id] + fun findTasksForUser(id: String): List = tasks.values.filter { it.user == id } + fun taskCount() : Int = tasks.size + fun deleteTask(id: String) : State = this.copy(tasks = this.tasks.minus(id)) fun setRecovered(): State = this.copy(recovered = true) } diff --git a/src/main/kotlin/blog/model/Tag.kt b/src/main/kotlin/blog/model/Tag.kt index a8d1bf6..9f21a73 100644 --- a/src/main/kotlin/blog/model/Tag.kt +++ b/src/main/kotlin/blog/model/Tag.kt @@ -3,7 +3,7 @@ package blog.model import akka.actor.typed.ActorRef import akka.pattern.StatusReply -data class Tag(override val id: Long, val label: String): Entity { +data class Tag(override val id: String, val label: String): Entity { fun toResponse() = TagResponse(id, label) } @@ -11,12 +11,12 @@ data class CreateTagReq(val label: String) { fun toCommand(replyTo: ActorRef>) = CreateTag(nextId(), label, replyTo) } -data class TagResponse(override val id: Long, val label: String): Response +data class TagResponse(override val id: String, val label: String): Response -data class CreateTag(val id: Long, val label: String, val replyTo: ActorRef>): Command { +data class CreateTag(val id: String, val label: String, val replyTo: ActorRef>): Command { fun toEvent() = TagCreated(id, label) } -data class TagCreated(val id: Long, val label: String): Event { +data class TagCreated(val id: String, val label: String): Event { fun toEntity() = Tag(id, label) } diff --git a/src/main/kotlin/blog/model/Task.kt b/src/main/kotlin/blog/model/Task.kt index 81410ea..f22a37f 100644 --- a/src/main/kotlin/blog/model/Task.kt +++ b/src/main/kotlin/blog/model/Task.kt @@ -4,7 +4,7 @@ import java.time.LocalDateTime import akka.Done import akka.actor.typed.ActorRef import akka.actor.typed.Scheduler -import akka.actor.typed.javadsl.AskPattern +import akka.actor.typed.javadsl.AskPattern.ask import akka.pattern.StatusReply import io.ktor.http.* import io.ktor.server.application.* @@ -20,8 +20,8 @@ enum class TaskStatus { } data class Task( - override val id: Long, - val user: Long, + override val id: String, + val user: String, val title: String, val slug: String, val body: String, @@ -44,28 +44,28 @@ data class Task( } data class CreateTaskRequest(val title: String, val body: String, val due: LocalDateTime): Request { - fun toCommand(user: Long, replyTo: ActorRef>) = CreateTask(user, title, body, due, replyTo) + fun toCommand(user: String, replyTo: ActorRef>) = CreateTask(user, title, body, due, replyTo) } -data class UpdateTaskRequest(val id: Long, val title: String?, val body: String?, val due: LocalDateTime?, val status: TaskStatus?): Request { - fun toCommand(user: Long, replyTo: ActorRef>) = UpdateTask(user, id, title, body, due, status, replyTo) +data class UpdateTaskRequest(val id: String, val title: String?, val body: String?, val due: LocalDateTime?, val status: TaskStatus?): Request { + fun toCommand(user: String, replyTo: ActorRef>) = UpdateTask(user, id, title, body, due, status, replyTo) } data class CreateTask( - val user: Long, + val user: String, val title: String, val body: String, val due: LocalDateTime, val replyTo: ActorRef>, - val id: Long = nextId() + val id: String = nextId() ) : Command { fun toEvent() = TaskCreated(id, user, title, body, due) } data class UpdateTask( - val user: Long, - val id: Long, + val user: String, + val id: String, val title: String?, val body: String?, val due: LocalDateTime?, @@ -75,14 +75,14 @@ data class UpdateTask( fun toEvent() = TaskUpdated(user, id, title, body, due, status) } -data class DeleteTask(val id: Long, val replyTo: ActorRef>): Command { +data class DeleteTask(val id: String, val replyTo: ActorRef>): Command { fun toEvent() = TaskDeleted(id) } data class TaskCreated( - val id: Long, - val user: Long, + val id: String, + val user: String, val title: String, val body: String, val due: LocalDateTime @@ -92,20 +92,20 @@ data class TaskCreated( } data class TaskUpdated( - val user: Long, - val id: Long, + val user: String, + val id: String, val title: String?, val body: String?, val due: LocalDateTime?, val status: TaskStatus?, ) : Event -data class TaskDeleted(val id: Long): Event +data class TaskDeleted(val id: String): Event data class TaskResponse( - val id: Long, - val user: Long, + val id: String, + val user: String, val title: String, val body: String, val due: String, @@ -118,7 +118,7 @@ fun Route.tasksRoute(processor: ActorRef, reader: Reader, scheduler: Sc post { val ctr = call.receive() val userId = user(call) ?: return@post call.respond(HttpStatusCode.Unauthorized, "Unauthorized") - AskPattern.ask(processor, { rt -> ctr.toCommand(userId, rt) }, timeout, scheduler).await().let { + ask(processor, { rt -> ctr.toCommand(userId, rt) }, timeout, scheduler).await().let { when { it.isSuccess -> { call.response.header("Location", "/api/tasks/${it.value.id}") @@ -134,7 +134,7 @@ fun Route.tasksRoute(processor: ActorRef, reader: Reader, scheduler: Sc call.respond(reader.allTasks(rows, start).map { it.toResponse() }) } get("{id?}") { - val id = call.parameters["id"]?.toLong() ?: return@get call.respond(HttpStatusCode.NotFound, "no task id specified") + val id = call.parameters["id"] ?: return@get call.respond(HttpStatusCode.NotFound, "no task id specified") (reader.find(id) ?: return@get call.respond(HttpStatusCode.NotFound, "task not found for $id")).let { call.respond(it.toResponse()) } @@ -142,7 +142,7 @@ fun Route.tasksRoute(processor: ActorRef, reader: Reader, scheduler: Sc put { val utr = call.receive() val userId = user(call) ?: return@put call.respond(HttpStatusCode.Unauthorized, "Unauthorized") - AskPattern.ask(processor, { rt -> utr.toCommand(userId, rt) }, timeout, scheduler).await().let { + ask(processor, { rt -> utr.toCommand(userId, rt) }, timeout, scheduler).await().let { when { it.isSuccess -> call.respond(HttpStatusCode.OK, it.value) else -> call.respond(HttpStatusCode.BadRequest, it.error.localizedMessage) @@ -150,9 +150,9 @@ fun Route.tasksRoute(processor: ActorRef, reader: Reader, scheduler: Sc } } delete("{id?}") { - val id = call.parameters["id"]?.toLong() ?: return@delete call.respond(HttpStatusCode.NotFound, "no task id specified") - reader.find(id) ?: return@delete call.respond(HttpStatusCode.NotFound, "task not found for $id") - AskPattern.ask(processor, { rt -> DeleteTask(id, rt) }, timeout, scheduler).await().let { + val id = call.parameters["id"] ?: return@delete call.respond(HttpStatusCode.NotFound, "no task id specified") + reader.findTask(id) ?: return@delete call.respond(HttpStatusCode.NotFound, "task not found for $id") + ask(processor, { rt -> DeleteTask(id, rt) }, timeout, scheduler).await().let { when { it.isSuccess -> call.respond(HttpStatusCode.OK, mapOf("task" to id)) else -> call.respond(HttpStatusCode.InternalServerError, it.error.localizedMessage) diff --git a/src/main/kotlin/blog/model/User.kt b/src/main/kotlin/blog/model/User.kt index 078d097..50a0ecd 100644 --- a/src/main/kotlin/blog/model/User.kt +++ b/src/main/kotlin/blog/model/User.kt @@ -10,7 +10,11 @@ import akka.pattern.StatusReply import com.auth0.jwt.JWT import com.auth0.jwt.algorithms.Algorithm import org.owasp.encoder.Encode +import io.hypersistence.tsid.TSID import io.ktor.http.* +import io.ktor.http.HttpStatusCode.Companion.BadRequest +import io.ktor.http.HttpStatusCode.Companion.NotFound +import io.ktor.http.HttpStatusCode.Companion.Unauthorized import io.ktor.server.application.* import io.ktor.server.auth.* import io.ktor.server.request.* @@ -24,7 +28,7 @@ data class RegisterUserRequest( val name: String, val password: String, ) : Request { - fun toCommand(replyTo: ActorRef>): CreateUser = CreateUser(this, replyTo) + fun toCommand(replyTo: ActorRef>): CreateUser = CreateUser(this, replyTo) } data class LoginRequest(val username: String, val password: String) @@ -34,10 +38,10 @@ data class CreateUser( val email: String, val name: String, val password: String, - val replyTo: ActorRef>, - val id: Long = nextId(), + val replyTo: ActorRef>, + val id: String = nextId(), ) : Command { - constructor(rur: RegisterUserRequest, replyTo: ActorRef>) : this(rur.email, rur.name, rur.password, replyTo) + constructor(rur: RegisterUserRequest, replyTo: ActorRef>) : this(rur.email, rur.name, rur.password, replyTo) fun toEvent() = UserCreated(id, Encode.forHtml(email), Encode.forHtml(name), password.hashed()) } @@ -45,22 +49,21 @@ data class CreateUser( // Events data class UserCreated( - val id: Long, + val id: String, val email: String, val name: String, val password: String, ) : Event { fun toEntity(): User = User(id, email, name, password) - fun toResponse(reader: Reader) = this.toEntity().toResponse(reader) } -data class UserDeleted(val id: Long) : Event +data class UserDeleted(val id: String) : Event data class UserDeletedByEmail(val email: String) : Event // Entitites data class User( - override val id: Long, + override val id: String, val email: String, val name: String, val password: String, @@ -70,7 +73,7 @@ data class User( id, email, name, - DTF.format(id.toTSID().instant.atZone(ZoneId.of("CET"))), + DTF.format(TSID.from(id).instant.atZone(ZoneId.of("CET"))), gravatar, reader.findNotesForUser(id).map(Note::toResponse), reader.findTasksForUser(id).map(Task::toResponse) @@ -80,7 +83,7 @@ data class User( // Responses data class UserResponse( - val id: Long, + val id: String, val email: String, val name: String, val joined: String, @@ -93,17 +96,20 @@ fun Route.loginRoute(reader: Reader, kfg: Konfig): Route = route("/api/login") { post { val loginRequest = call.receive() - val user: User = reader.findUserByEmail(loginRequest.username) ?: return@post call.respond(HttpStatusCode.Unauthorized, "user not found") - val token: String = JWT.create() - .withAudience(kfg.jwt.audience) - .withClaim("uid", user.id) - .withExpiresAt(Instant.now().plusMillis(kfg.jwt.expiresIn)) - .withIssuer(kfg.jwt.issuer) - .sign(Algorithm.HMAC256(kfg.jwt.secret)) + val user = reader.findUserByEmail(loginRequest.username) ?: return@post call.respondText("user not found", status = Unauthorized) + val token = createJwtToken(user.id, kfg) call.respond(hashMapOf("token" to token)) } } +fun createJwtToken(uid: String, kfg: Konfig): String = JWT.create() + .withAudience(kfg.jwt.audience) + .withClaim("uid", uid) + .withExpiresAt(Instant.now().plusMillis(kfg.jwt.expiresIn)) + .withIssuer(kfg.jwt.issuer) + .sign(Algorithm.HMAC256(kfg.jwt.secret)) + + fun Route.usersRoute(processor: ActorRef, reader: Reader, scheduler: Scheduler, kfg: Konfig): Route = route("/api/users") { get { @@ -113,22 +119,22 @@ fun Route.usersRoute(processor: ActorRef, reader: Reader, scheduler: Sc } authenticate(kfg.jwt.realm) { get("/tasks") { - val userId = user(call) ?: return@get call.respond(HttpStatusCode.Unauthorized, "Unauthorized") + val userId = user(call) ?: return@get call.respondText("Unauthorized", status = Unauthorized) call.respond(reader.findTasksForUser(userId).map { it.toResponse() }) } get("/me") { - val userId = user(call) ?: return@get call.respond(HttpStatusCode.Unauthorized, "Unauthorized") - val user: User = reader.find(userId) ?: return@get call.respond(HttpStatusCode.NotFound, "User not found") + val id = user(call) ?: return@get call.respondText("Unauthorized", status = Unauthorized) + val user = reader.findUser(id) ?: return@get call.respondText("user not found for $id", status = NotFound) call.respond(user.toResponse(reader)) } get("/notes") { - val userId = user(call) ?: return@get call.respond(HttpStatusCode.Unauthorized, "Unauthorized") + val userId = user(call) ?: return@get call.respond(Unauthorized, "Unauthorized") call.respond(reader.findNotesForUser(userId).map { it.toResponse() }) } } get("{id?}") { - val id = call.parameters["id"] ?: return@get call.respondText("no user id specified", status = HttpStatusCode.BadRequest) - val user: User = reader.find(id.toLong()) ?: return@get call.respondText("user not found for $id", status = HttpStatusCode.NotFound) + val id = call.parameters["id"] ?: return@get call.respondText("no user id specified", status = BadRequest) + val user: User = reader.findUser(id) ?: return@get call.respondText("user not found for $id", status = NotFound) call.respond(user.toResponse(reader)) } post { @@ -136,9 +142,9 @@ fun Route.usersRoute(processor: ActorRef, reader: Reader, scheduler: Sc ask(processor, { rt -> cnu.toCommand(rt) }, Duration.ofMinutes(1), scheduler).await().let { if (it.isSuccess) { call.response.header("Location", "/api/users/${it.value.id}") - call.respond(HttpStatusCode.Created, it.value) + call.respond(HttpStatusCode.Created, hashMapOf("token" to createJwtToken(it.value.id, kfg))) } else { - call.respond(HttpStatusCode.BadRequest, it.error.localizedMessage) + call.respondText(it.error.message ?: "unknown error", status = BadRequest) } } } diff --git a/src/main/kotlin/blog/model/model.kt b/src/main/kotlin/blog/model/model.kt index 4eba2c7..cb1eb61 100644 --- a/src/main/kotlin/blog/model/model.kt +++ b/src/main/kotlin/blog/model/model.kt @@ -1,6 +1,5 @@ package blog.model -import io.hypersistence.tsid.TSID import java.io.Serializable import java.net.InetAddress import java.nio.charset.StandardCharsets @@ -10,6 +9,7 @@ import java.time.Duration import java.time.LocalDateTime import java.time.format.DateTimeFormatter import java.util.Locale +import io.hypersistence.tsid.TSID import io.ktor.server.application.* import io.ktor.server.auth.* import io.ktor.server.auth.jwt.* @@ -18,10 +18,10 @@ interface Request : Serializable interface Command : Serializable interface Event : Serializable interface Entity : Serializable { - val id: Long + val id: String } interface Response : Serializable { - val id: Long + val id: String } object Hasher { @@ -38,11 +38,10 @@ private val idFactory = TSID.Factory.builder() .withRandom(SecureRandom.getInstance("SHA1PRNG", "SUN")) .withNodeBits(8) .withNode(InetAddress.getLocalHost().address[3].toInt()and(0xFF)).build() -fun nextId(): Long = idFactory.generate().toLong() +fun nextId(): String = idFactory.generate().toString() fun slugify(s: String): String = s.trim().replace(" ", " ").lowercase().replace("[^ a-z0-9]".toRegex(), "").replace(' ', '-') -fun Long.toTSID(): TSID = TSID.from(this) fun String.hashed() = Hasher.hash(this) data class Counts( @@ -69,4 +68,4 @@ data class Konfig( ) val timeout: Duration = Duration.ofSeconds(10) -fun user(call: ApplicationCall): Long? = call.principal()?.payload?.getClaim("uid")?.asLong() +fun user(call: ApplicationCall): String? = call.principal()?.payload?.getClaim("uid")?.asString() diff --git a/src/main/kotlin/blog/module/Authentication.kt b/src/main/kotlin/blog/module/Authentication.kt index 4e1f108..b7424a7 100644 --- a/src/main/kotlin/blog/module/Authentication.kt +++ b/src/main/kotlin/blog/module/Authentication.kt @@ -1,13 +1,13 @@ package blog.module -import blog.model.Konfig import com.auth0.jwt.JWT import com.auth0.jwt.algorithms.Algorithm -import io.ktor.http.* +import io.ktor.http.HttpStatusCode.Companion.Unauthorized import io.ktor.server.application.* import io.ktor.server.auth.* import io.ktor.server.auth.jwt.* import io.ktor.server.response.* +import blog.model.Konfig fun Application.authentication(kfg: Konfig) { install(Authentication) { @@ -16,14 +16,14 @@ fun Application.authentication(kfg: Konfig) { JWT.require(Algorithm.HMAC256(kfg.jwt.secret)).withAudience(kfg.jwt.audience).withIssuer(kfg.jwt.issuer).build() ) validate { credential -> - if (credential.payload.getClaim("uid").asLong() != null) { + if (credential.payload.getClaim("uid") != null) { JWTPrincipal(credential.payload) } else { null } } challenge {defaultSchema, realm -> - call.respond(HttpStatusCode.Unauthorized, "token invalid or expired ($defaultSchema, $realm}") + call.respond(Unauthorized, "token invalid or expired ($defaultSchema, $realm}") } } } diff --git a/src/main/kotlin/blog/module/Status.kt b/src/main/kotlin/blog/module/Status.kt index a79a659..105dcda 100644 --- a/src/main/kotlin/blog/module/Status.kt +++ b/src/main/kotlin/blog/module/Status.kt @@ -1,7 +1,8 @@ package blog.module -import io.ktor.http.* +import io.ktor.http.HttpStatusCode.Companion.BadRequest import io.ktor.server.application.* +import io.ktor.server.plugins.* import io.ktor.server.plugins.requestvalidation.* import io.ktor.server.plugins.statuspages.* import io.ktor.server.response.* @@ -9,7 +10,10 @@ import io.ktor.server.response.* fun Application.status() { install(StatusPages) { exception { call, cause -> - call.respond(HttpStatusCode.BadRequest, cause.reasons.joinToString()) + call.respondText(cause.reasons.joinToString(", "), status = BadRequest) + } + exception { call, _ -> + call.respondText("invalid json in request body", status = BadRequest) } } } diff --git a/src/main/kotlin/blog/read/Reader.kt b/src/main/kotlin/blog/read/Reader.kt index 0498365..025a0f4 100644 --- a/src/main/kotlin/blog/read/Reader.kt +++ b/src/main/kotlin/blog/read/Reader.kt @@ -1,5 +1,7 @@ package blog.read +import java.util.concurrent.ConcurrentHashMap +import org.slf4j.LoggerFactory import blog.model.Counts import blog.model.Event import blog.model.Note @@ -14,41 +16,39 @@ import blog.model.User import blog.model.UserCreated import blog.model.UserDeleted import blog.model.UserDeletedByEmail -import org.slf4j.LoggerFactory -import java.util.concurrent.ConcurrentHashMap class Reader( - private val users: MutableMap = ConcurrentHashMap(), - private val tasks: MutableMap = ConcurrentHashMap(), - private val notes: MutableMap = ConcurrentHashMap(), + private val users: MutableMap = ConcurrentHashMap(), + private val tasks: MutableMap = ConcurrentHashMap(), + private val notes: MutableMap = ConcurrentHashMap(), private var serverReady: Boolean = false, private var recovered: Boolean = false, ) { private val logger = LoggerFactory.getLogger("Reader") - fun findUser(id: Long): User? = users[id] + fun findUser(id: String): User? = users[id] fun findUserByEmail(email: String): User? = users.values.find { it.email == email } fun exists(email: String): Boolean = findUserByEmail(email) != null private fun canAuthenticate(email: String, password: String): Boolean = users.values.find { it.email == email && it.password == password } != null fun canNotAuthenticate(email: String, password: String): Boolean = !canAuthenticate(email, password) fun allUsers(rows: Int = 10, start: Int = 0): List = users.values.drop(start * rows).take(rows) - inline fun find(id: Long): T? { + inline fun find(id: String): T? { return when (T::class) { User::class -> findUser(id) as T? Note::class -> findNote(id) as T? - Task::class -> findTask(id) as T? + Task.clazz -> findTask(id) as T? else -> throw IllegalArgumentException("Reader.find: Unsupported type: ${T::class}") } } - fun findNote(id: Long): Note? = notes[id] - fun findNotesForUser(id: Long): List = notes.values.filter { it.user == id } + fun findNote(id: String): Note? = notes[id] + fun findNotesForUser(id: String): List = notes.values.filter { it.user == id } fun allNotes(rows: Int = 10, start: Int = 0): List = notes.values.drop(start * rows).take(rows) - fun findTask(id: Long): Task? = tasks[id] - fun notExistsTask(id: Long): Boolean = !tasks.containsKey(id) - fun findTasksForUser(id: Long): List = tasks.values.filter { it.user == id } + fun findTask(id: String): Task? = tasks[id] + fun notExistsTask(id: String): Boolean = !tasks.containsKey(id) + fun findTasksForUser(id: String): List = tasks.values.filter { it.user == id } fun allTasks(rows: Int = 10, start: Int = 0): List = tasks.values.drop(start * rows).take(rows) fun setServerReady() { serverReady = true } diff --git a/src/main/kotlin/blog/write/Processor.kt b/src/main/kotlin/blog/write/Processor.kt index fefc336..bfa6733 100644 --- a/src/main/kotlin/blog/write/Processor.kt +++ b/src/main/kotlin/blog/write/Processor.kt @@ -59,7 +59,7 @@ class Processor(pid: PersistenceId, private val reader: Reader) : EventSourcedBe if (state.findUserByEmail(cmd.email) != null) { Effect().none().thenReply(cmd.replyTo) { StatusReply.error("${cmd.email} already registered") } } else { - cmd.toEvent().let { Effect().persist(it).thenReply(cmd.replyTo) { _ -> StatusReply.success(it.toResponse(reader)) } } + cmd.toEvent().let { Effect().persist(it).thenReply(cmd.replyTo) { _ -> StatusReply.success(it.toEntity()) } } } private fun onCreateNote(state: State, cmd: CreateNote): Effect = diff --git a/src/test/kotlin/blog/ApiTests.kt b/src/test/kotlin/blog/ApiTests.kt index dd72a02..529e294 100644 --- a/src/test/kotlin/blog/ApiTests.kt +++ b/src/test/kotlin/blog/ApiTests.kt @@ -1,9 +1,11 @@ package blog -import blog.model.TaskResponse -import blog.model.UserResponse -import blog.model.Counts +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.cio.* @@ -12,15 +14,19 @@ import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.serialization.jackson.* import kotlinx.coroutines.runBlocking -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.AfterAll -import org.junit.jupiter.api.BeforeAll -import org.junit.jupiter.api.Test -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation +import blog.model.Counts +import blog.model.TaskResponse +import blog.model.UserResponse class ApiTests { - private var client: HttpClient? = null + private val client: HttpClient = HttpClient(CIO) { + install(ClientContentNegotiation) { + jackson { + registerModules(JavaTimeModule()) + } + } + } @BeforeAll fun before() { @@ -30,34 +36,26 @@ class ApiTests { Thread.sleep(100) index++ } - client = HttpClient(CIO) { - install(ClientContentNegotiation) { - jackson { - registerModules(JavaTimeModule()) - } - } - } } @AfterAll fun after() { MainTest.stop() - client?.close() + client.close() } @Test fun `register and retrieve new user`() { - assertThat(client).isNotNull runBlocking { val email = "one@test.er" - var response: HttpResponse = client!!.post("http://localhost:8181/api/users") { + var response: HttpResponse = client.post("http://localhost:8181/api/users") { contentType(ContentType.Application.Json) setBody("""{"email":"$email","name":"Tester","password":"welkom123"}""") } assertThat(response.status.value).isEqualTo(201) assertThat(response.headers["Location"]).startsWith("/api/users") val loc = response.headers["Location"] - response = client!!.get("http://localhost:8181$loc") + response = client.get("http://localhost:8181$loc") assertThat(response.status.value).isEqualTo(200) assertThat(response.headers["Content-Type"]).isEqualTo("application/json; charset=UTF-8") val ur = response.body() @@ -68,50 +66,62 @@ class ApiTests { @Test fun `try to register user with bad json`() { - assertThat(client).isNotNull runBlocking { - var response = client!!.post("http://localhost:8181/api/users") { + var response = client.post("http://localhost:8181/api/users") { contentType(ContentType.Application.Json) setBody("""{"email":"bad email address @ where.here","name":"Tester","password":"welkom123"}""") } assertThat(response.status.value).isEqualTo(400) assertThat(response.body()).isEqualTo("invalid email address") - response = client!!.post("http://localhost:8181/api/users") { + response = client.post("http://localhost:8181/api/users") { contentType(ContentType.Application.Json) setBody("""{"email":"good@where.here","name":"","password":"welkom123"}""") } assertThat(response.status.value).isEqualTo(400) assertThat(response.body()).isEqualTo("invalid name") - response = client!!.post("http://localhost:8181/api/users") { + response = client.post("http://localhost:8181/api/users") { contentType(ContentType.Application.Json) setBody("""{"email":"good@where.here","name":"Toaster","password":""}""") } assertThat(response.status.value).isEqualTo(400) assertThat(response.body()).isEqualTo("invalid password") - response = client!!.post("http://localhost:8181/api/users") { + response = client.post("http://localhost:8181/api/users") { contentType(ContentType.Application.Json) setBody("""{"email":"good@where.here","name":"Toaster","password":"passwo"}""") } assertThat(response.status.value).isEqualTo(400) assertThat(response.body()).isEqualTo("password too short") + + response = client.post("http://localhost:8181/api/users") { + contentType(ContentType.Application.Json) + setBody("""{"eemail":"good@where.here","name":"Toaster","password":"passwo"}""") + } + assertThat(response.status.value).isEqualTo(400) + assertThat(response.body()).isEqualTo("invalid json in request body") + + response = client.post("http://localhost:8181/api/users") { + contentType(ContentType.Application.Json) + setBody("""{"email":"good@where.here","naame":"Toaster","password":"passwo"}""") + } + assertThat(response.status.value).isEqualTo(400) + assertThat(response.body()).isEqualTo("invalid json in request body") } } @Test fun `try to login`() { - assertThat(client).isNotNull runBlocking { val email = "login@here.now" - var response: HttpResponse = client!!.post("http://localhost:8181/api/users") { + var response: HttpResponse = client.post("http://localhost:8181/api/users") { contentType(ContentType.Application.Json) setBody("""{"email":"$email","name":"Tester","password":"welkom123"}""") } assertThat(response.status.value).isEqualTo(201) - response = client!!.post("http://localhost:8181/api/login") { + response = client.post("http://localhost:8181/api/login") { contentType(ContentType.Application.Json) setBody("""{"username":"$email","password":"welkom123"}""") } @@ -120,7 +130,7 @@ class ApiTests { val token = response.body() assertThat(token).isNotNull - response = client!!.get("http://localhost:8181/api/users/tasks") { + response = client.get("http://localhost:8181/api/users/tasks") { header("Authorization", "Bearer ${token.token}") } assertThat(response.status.value).isEqualTo(200) @@ -129,44 +139,43 @@ class ApiTests { @Test fun `fail to login`() { - assertThat(client).isNotNull runBlocking { val email = "fail@to.login" - var response: HttpResponse = client!!.post("http://localhost:8181/api/users") { + var response: HttpResponse = client.post("http://localhost:8181/api/users") { contentType(ContentType.Application.Json) setBody("""{"email":"$email","name":"Tester","password":"welkom123"}""") } assertThat(response.status.value).isEqualTo(201) - response = client!!.post("http://localhost:8181/api/login") { + response = client.post("http://localhost:8181/api/login") { contentType(ContentType.Application.Json) setBody("""{"username":"$email","password":""}""") } assertThat(response.status).isEqualTo(HttpStatusCode.BadRequest) assertThat(response.body()).isEqualTo("invalid password") - response = client!!.post("http://localhost:8181/api/login") { + response = client.post("http://localhost:8181/api/login") { contentType(ContentType.Application.Json) setBody("""{"username":"$email","password":"verkeerd!"}""") } assertThat(response.status).isEqualTo(HttpStatusCode.BadRequest) assertThat(response.body()).isEqualTo("username or password not correct") - response = client!!.post("http://localhost:8181/api/login") { + response = client.post("http://localhost:8181/api/login") { contentType(ContentType.Application.Json) setBody("""{"username":"","password":"password"}""") } assertThat(response.status).isEqualTo(HttpStatusCode.BadRequest) assertThat(response.body()).isEqualTo("invalid username") - response = client!!.post("http://localhost:8181/api/login") { + response = client.post("http://localhost:8181/api/login") { contentType(ContentType.Application.Json) setBody("""{"username":"","password":""}""") } assertThat(response.status).isEqualTo(HttpStatusCode.BadRequest) assertThat(response.body()).isEqualTo("invalid username") - response = client!!.post("http://localhost:8181/api/login") { + response = client.post("http://localhost:8181/api/login") { contentType(ContentType.Application.Json) setBody("""{}""") } @@ -176,22 +185,21 @@ class ApiTests { @Test fun `check some nice info`() { - assertThat(client).isNotNull runBlocking { - var response = client!!.get("http://localhost:8181/info/ready") + var response = client.get("http://localhost:8181/info/ready") assertThat(response.status).isEqualTo(HttpStatusCode.OK) - response = client!!.get("http://localhost:8181/info/alive") + response = client.get("http://localhost:8181/info/alive") assertThat(response.status).isEqualTo(HttpStatusCode.OK) - response = client!!.get("http://localhost:8181/info/counts") + response = client.get("http://localhost:8181/info/counts") assertThat(response.status).isEqualTo(HttpStatusCode.OK) val counts = response.body() assertThat(counts.users).isGreaterThanOrEqualTo(0) assertThat(counts.notes).isGreaterThanOrEqualTo(0) assertThat(counts.tasks).isGreaterThanOrEqualTo(0) - response = client!!.get("http://localhost:8181/info/stati") + response = client.get("http://localhost:8181/info/stati") assertThat(response.status).isEqualTo(HttpStatusCode.OK) val stati: Stati = response.body() assertThat(stati).isNotNull @@ -201,17 +209,16 @@ class ApiTests { @Test fun `make a task`() { - assertThat(client).isNotNull runBlocking { val email = "todo@test.er" - var response: HttpResponse = client!!.post("http://localhost:8181/api/users") { + var response: HttpResponse = client.post("http://localhost:8181/api/users") { contentType(ContentType.Application.Json) setBody("""{"email":"$email","name":"Tester","password":"welkom123"}""") } assertThat(response.status.value).isEqualTo(201) val loc = response.headers["Location"] - response = client!!.post("http://localhost:8181/api/login") { + response = client.post("http://localhost:8181/api/login") { contentType(ContentType.Application.Json) setBody("""{"username":"$email","password":"welkom123"}""") } @@ -220,12 +227,12 @@ class ApiTests { val token = response.body() assertThat(token).isNotNull - response = client!!.get("http://localhost:8181/api/users/tasks") { + response = client.get("http://localhost:8181/api/users/tasks") { header(HttpHeaders.Authorization, "Bearer ${token.token}") } assertThat(response.status.value).isEqualTo(200) - response = client!!.post("http://localhost:8181/api/tasks") { + response = client.post("http://localhost:8181/api/tasks") { contentType(ContentType.Application.Json) header(HttpHeaders.Authorization, "Bearer ${token.token}") setBody("""{"title": "Title", "body": "Body Text", "due":"2025-01-01T00:00:00"}""") @@ -234,14 +241,21 @@ class ApiTests { val body = response.body() assertThat(body.title).isEqualTo("Title") - response = client!!.get("http://localhost:8181$loc") + response = client.get("http://localhost:8181$loc") assertThat(response.status).isEqualTo(HttpStatusCode.OK) val ur = response.body() assertThat(ur.tasks).hasSize(1) println("ur: $ur") - response = client!!.get("http://localhost:8181/info/counts") + response = client.get("http://localhost:8181/info/counts") println(response.body()) + + response = client.put("http://localhost:8181/api/tasks") { + contentType(ContentType.Application.Json) + header(HttpHeaders.Authorization, "Bearer ${token.token}") + setBody("""{"id": ${body.id},"status":"DOING"}""") + } + println("response: $response") } } } diff --git a/src/test/kotlin/blog/MainTest.kt b/src/test/kotlin/blog/MainTest.kt index 296b39a..78097c1 100644 --- a/src/test/kotlin/blog/MainTest.kt +++ b/src/test/kotlin/blog/MainTest.kt @@ -63,7 +63,7 @@ object MainTest { authenticate(kfg.jwt.realm) { get("/api/test") { val principal = call.principal() ?: return@get call.respond(HttpStatusCode.Unauthorized, "no principal") - val userId: Long? = principal.payload.getClaim("uid").asLong() + val userId = principal.payload.getClaim("uid").asString() val expireAt = principal.expiresAt?.time?.minus(System.currentTimeMillis()) var username = "???" if (userId != null) { diff --git a/src/test/kotlin/blog/model/NoteTests.kt b/src/test/kotlin/blog/model/NoteTests.kt index 6b18ae7..c992ac2 100644 --- a/src/test/kotlin/blog/model/NoteTests.kt +++ b/src/test/kotlin/blog/model/NoteTests.kt @@ -18,7 +18,7 @@ class NoteTests { private val probeNoteRes = testKit.createTestProbe>().ref private val probeNoteDel = testKit.createTestProbe>().ref - private val userId: Long = nextId() + private val userId = nextId() @Test fun `create request to command to event to entity`() { diff --git a/src/test/kotlin/blog/model/TaskTests.kt b/src/test/kotlin/blog/model/TaskTests.kt index 4d994c2..d6ebb3b 100644 --- a/src/test/kotlin/blog/model/TaskTests.kt +++ b/src/test/kotlin/blog/model/TaskTests.kt @@ -6,6 +6,7 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import java.time.LocalDateTime import java.util.UUID +import io.hypersistence.tsid.TSID class TaskTests { @@ -43,7 +44,7 @@ class TaskTests { assertThat(task.user).isEqualTo(userId).isEqualTo(tc.user).isEqualTo(ct.user) val res = task.toResponse() - assertThat(res.id).isEqualTo(task.id) + assertThat(res.id).isEqualTo(TSID.from(task.id).toString()) assertThat(res.title).isEqualTo(task.title) assertThat(res.body).isEqualTo(task.body) assertThat(res.due).isEqualTo(DTF.format(task.due)) @@ -53,7 +54,7 @@ class TaskTests { @Test fun `update task request, command and event`() { val taskId = nextId() - val utr = UpdateTaskRequest(taskId, "new title", "new body", now().plusDays(3), TaskStatus.DOING) + val utr = UpdateTaskRequest(TSID.from(taskId).toString(), "new title", "new body", now().plusDays(3), TaskStatus.DOING) val ut = utr.toCommand(userId, probeTaskRes) assertThat(ut.id).isEqualTo(taskId) assertThat(ut.user).isEqualTo(userId) diff --git a/src/test/kotlin/blog/model/UserTests.kt b/src/test/kotlin/blog/model/UserTests.kt index e7768f6..974e94d 100644 --- a/src/test/kotlin/blog/model/UserTests.kt +++ b/src/test/kotlin/blog/model/UserTests.kt @@ -15,7 +15,7 @@ class UserTests { akka.persistence.snapshot-store.local.dir = "build/snapshot-${UUID.randomUUID()}" """ ) - private val probe = testKit.createTestProbe>().ref + private val probe = testKit.createTestProbe>().ref @Test fun `request to command`() { @@ -38,7 +38,7 @@ class UserTests { @Test fun `event to entity`() { - val uc = UserCreated(1L, "jurjen@vorhauer.nl", "Jurjen", "password") + val uc = UserCreated(nextId(), "jurjen@vorhauer.nl", "Jurjen", "password") val u = uc.toEntity() assertThat(u.id).isEqualTo(uc.id) assertThat(u.email).isEqualTo(uc.email) diff --git a/src/test/kotlin/blog/write/ProcessorTests.kt b/src/test/kotlin/blog/write/ProcessorTests.kt index ac6cabb..056b44c 100644 --- a/src/test/kotlin/blog/write/ProcessorTests.kt +++ b/src/test/kotlin/blog/write/ProcessorTests.kt @@ -1,17 +1,17 @@ package blog.write +import java.util.UUID +import java.util.concurrent.atomic.AtomicInteger import akka.actor.testkit.typed.javadsl.TestKitJunitResource import akka.pattern.StatusReply +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test import blog.model.CreateNote import blog.model.CreateUser import blog.model.NoteResponse import blog.model.UpdateNote -import blog.model.UserResponse +import blog.model.User import blog.read.Reader -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.util.UUID -import java.util.concurrent.atomic.AtomicInteger class ProcessorTests { private val counter = AtomicInteger(0) @@ -28,12 +28,12 @@ class ProcessorTests { fun `should register user`() { val reader = Reader() val prc = testKit.spawn(Processor.create(aid(), reader)) - val probe = testKit.createTestProbe>() + val probe = testKit.createTestProbe>() prc.tell(CreateUser("a@b.c","a", "welkom123", probe.ref)) val result = probe.receiveMessage() assertThat(result.isSuccess).isTrue - assertThat(result.value.javaClass.simpleName).isEqualTo("UserResponse") + assertThat(result.value.javaClass.simpleName).isEqualTo("User") val ur = reader.findUserByEmail("a@b.c") assertThat(ur).isNotNull assertThat(ur?.id).isEqualTo(result.value.id) @@ -46,7 +46,7 @@ class ProcessorTests { fun `should not register a user twice`() { val reader = Reader() val prc = testKit.spawn(Processor.create(aid(), reader)) - val probe = testKit.createTestProbe>() + val probe = testKit.createTestProbe>() prc.tell(CreateUser("a@b.c","a", "welkom123", probe.ref)) val result = probe.receiveMessage() @@ -68,7 +68,7 @@ class ProcessorTests { fun `should save a note`() { val reader = Reader() val prc = testKit.spawn(Processor.create(aid(), reader)) - val userProbe = testKit.createTestProbe>() + val userProbe = testKit.createTestProbe>() val noteProbe = testKit.createTestProbe>() prc.tell(CreateUser("a@b.c","a", "welkom123", userProbe.ref)) diff --git a/src/test/requests/users-v2.http b/src/test/requests/users-v2.http index c30269d..cb6cc5e 100644 --- a/src/test/requests/users-v2.http +++ b/src/test/requests/users-v2.http @@ -52,7 +52,7 @@ Authorization: Bearer {{authorized}} { "title": "Make Vault story", - "body": "In order to deploy to production, we need to store secrets in the Vault", + "body": "In order to deploy to production, we need to store secrets in the Vault", "due": "2024-03-01T09:00:00" } @@ -64,3 +64,7 @@ GET {{host}}/info/ready ### get-counts GET {{host}}/info/counts + +### get-one-task +GET {{host}}/api/tasks/545930282606711300 +Authorization: Bearer {{authorized}}