From a3f11cadf765728852534ab4281d06414e6352dd Mon Sep 17 00:00:00 2001 From: Jurjen Vorhauer Date: Fri, 1 Mar 2024 10:13:49 +0100 Subject: [PATCH] cleanup datetime types; added received timestamp for events. --- README.md | 2 +- build.gradle.kts | 7 +++-- src/main/kotlin/blog/Main.kt | 2 +- src/main/kotlin/blog/model/Note.kt | 40 +++++++++++++++--------- src/main/kotlin/blog/model/Tag.kt | 7 ++++- src/main/kotlin/blog/model/Task.kt | 25 +++++++++------ src/main/kotlin/blog/model/User.kt | 11 +++++-- src/main/kotlin/blog/model/model.kt | 25 ++++++++++----- src/main/kotlin/blog/read/Reader.kt | 1 + src/main/kotlin/blog/write/Processor.kt | 2 +- src/test/kotlin/blog/ApiTests.kt | 7 +++++ src/test/kotlin/blog/model/NoteTests.kt | 2 ++ src/test/kotlin/blog/model/StateTests.kt | 3 +- src/test/kotlin/blog/model/TaskTests.kt | 28 +++++++++++++---- src/test/kotlin/blog/model/UserTests.kt | 2 ++ src/test/kotlin/blog/read/ReaderTests.kt | 6 ++-- 16 files changed, 119 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 6987714..05935a4 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ## Status [![build](https://github.com/jvorhauer/konomas/actions/workflows/gradle.yml/badge.svg)](https://github.com/jvorhauer/noviblog/actions/workflows/gradle.yml) -[![coverage](https://codecov.io/gh/jvorhauer/noviblog/branch/main/graph/badge.svg?token=Nn5OmNCOEY)](https://codecov.io/gh/jvorhauer/noviblog) +[![coverage](https://codecov.io/gh/jvorhauer/konomas/branch/main/graph/badge.svg?token=Nn5OmNCOEY)](https://codecov.io/gh/jvorhauer/noviblog) An Event Sourced version of the backend for the FrontEnd solution. diff --git a/build.gradle.kts b/build.gradle.kts index 4a17229..408dafb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,6 +37,9 @@ repositories { maven { url = uri("https://oss.sonatype.org/content/repositories/releases") } + maven { + url = uri("https://jitpack.io") + } } dependencies { @@ -91,8 +94,8 @@ tasks.withType { kotlinOptions { freeCompilerArgs = listOf("-Xjsr305=strict") jvmTarget = "21" - languageVersion = "1.9" - allWarningsAsErrors = true + languageVersion = "2.0" + allWarningsAsErrors = false } } tasks.withType { diff --git a/src/main/kotlin/blog/Main.kt b/src/main/kotlin/blog/Main.kt index 10d67f8..9dabd2f 100644 --- a/src/main/kotlin/blog/Main.kt +++ b/src/main/kotlin/blog/Main.kt @@ -26,7 +26,7 @@ import blog.read.Reader import blog.read.info import blog.write.Processor -const val pid: String = "28" +const val pid: String = "29" 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 0f59b21..e84558b 100644 --- a/src/main/kotlin/blog/model/Note.kt +++ b/src/main/kotlin/blog/model/Note.kt @@ -43,18 +43,31 @@ data class DeleteNote(val id: String, val rt: ActorRef>): Comm val toEvent get() = NoteDeleted(id) } -data class NoteCreated(val id: String, val user: String, val title: String, val body: String) : Event { + +data class NoteCreated( + val id: String, + val user: String, + val title: String, + val body: String, + override val received: ZonedDateTime = znow +) : Event { val toEntity get() = Note(id, user, title, title.slug, body) - val toResponse get() = this.toEntity.toResponse() + val toResponse get() = this.toEntity.toResponse } -data class NoteUpdated(val id: String, val user: String, val title: String?, val body: String?): Event { - val timestamp: ZonedDateTime get() = TSID.from(id).instant.atZone(CET) -} +data class NoteUpdated( + val id: String, + val user: String, + val title: String?, + val body: String?, + override val received: ZonedDateTime = znow +): Event -data class NoteDeleted(val id: String): Event +data class NoteDeleted( + val id: String, + override val received: ZonedDateTime = znow +): Event -data class NoteDelta(val updated: ZonedDateTime, val what: String) data class Note( override val id: String, @@ -63,19 +76,19 @@ data class Note( val slug: String, val body: String, val created: ZonedDateTime = TSID.from(id).instant.atZone(CET), - val updated: ZonedDateTime = znow, - val events: List = listOf() + val updated: ZonedDateTime = znow ): Entity { constructor(id: String, user: String, title: String, body: String): this(id, user, title, title.slug, body) fun update(nu: NoteUpdated): Note = this.copy( - title = nu.title ?: this.title, slug = nu.title?.slug ?: this.slug, body = nu.body ?: this.body, updated = znow, events = this.events + nu + title = nu.title ?: this.title, slug = nu.title?.slug ?: this.slug, body = nu.body ?: this.body, updated = nu.received ) - fun toResponse() = NoteResponse(id, user, DTF.format(created), DTF.format(updated), title, slug, body, events.map { NoteDelta(it.timestamp, "???") }) + val toResponse get() = NoteResponse(id, user, created.fmt, updated.fmt, title, slug, body) override fun equals(other: Any?): Boolean = equals(this, other) override fun hashCode(): Int = id.hashCode() } + data class NoteResponse( val id: String, val user: String, @@ -84,7 +97,6 @@ data class NoteResponse( val title: String, val slug: String, val body: String, - val deltas: List ) fun Route.notesRoute(processor: ActorRef, reader: Reader, scheduler: Scheduler, kfg: Konfig) = @@ -105,12 +117,12 @@ fun Route.notesRoute(processor: ActorRef, reader: Reader, scheduler: Sc get { val rows = call.request.queryParameters["rows"]?.toInt() ?: 10 val start = call.request.queryParameters["start"]?.toInt() ?: 0 - call.respond(reader.allNotes(rows, start).map { it.toResponse() }) + call.respond(reader.allNotes(rows, start).map { it.toResponse }) } get("{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()) + call.respond(note.toResponse) } put { val unr = call.receive() diff --git a/src/main/kotlin/blog/model/Tag.kt b/src/main/kotlin/blog/model/Tag.kt index f3a079b..c115c66 100644 --- a/src/main/kotlin/blog/model/Tag.kt +++ b/src/main/kotlin/blog/model/Tag.kt @@ -1,5 +1,6 @@ package blog.model +import java.time.ZonedDateTime import akka.actor.typed.ActorRef import akka.actor.typed.Scheduler import akka.actor.typed.javadsl.AskPattern.ask @@ -30,7 +31,11 @@ data class CreateTag(val id: String, val label: String, val replyTo: ActorRef>) = CreateTask(user, title.encode, body.encode, due, replyTo) + fun toCommand(user: String, replyTo: ActorRef>) = CreateTask(user, title.encode, body.encode, due.atZone(CET), 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.mencode, body.mencode, due, status, replyTo) + fun toCommand(user: String, replyTo: ActorRef>) = UpdateTask(user, id, title.mencode, body.mencode, due?.atZone(CET), status, replyTo) } @@ -61,7 +61,7 @@ data class CreateTask( val user: String, val title: String, val body: String, - val due: LocalDateTime, + val due: ZonedDateTime, val replyTo: ActorRef>, val id: String = nextId ) : Command { @@ -73,7 +73,7 @@ data class UpdateTask( val id: String, val title: String?, val body: String?, - val due: LocalDateTime?, + val due: ZonedDateTime?, val status: TaskStatus?, val replyTo: ActorRef> ) : Command { @@ -90,7 +90,8 @@ data class TaskCreated( val user: String, val title: String, val body: String, - val due: LocalDateTime + val due: ZonedDateTime, + override val received: ZonedDateTime = znow ) : Event { val toEntity get() = Task(id, user, title, title.slug, body, due) val toResponse get() = toEntity.toResponse @@ -101,11 +102,15 @@ data class TaskUpdated( val id: String, val title: String?, val body: String?, - val due: LocalDateTime?, + val due: ZonedDateTime?, val status: TaskStatus?, + override val received: ZonedDateTime = znow ) : Event -data class TaskDeleted(val id: String): Event +data class TaskDeleted( + val id: String, + override val received: ZonedDateTime = znow +): Event data class TaskResponse( diff --git a/src/main/kotlin/blog/model/User.kt b/src/main/kotlin/blog/model/User.kt index f0b2da5..9fc2cc9 100644 --- a/src/main/kotlin/blog/model/User.kt +++ b/src/main/kotlin/blog/model/User.kt @@ -69,6 +69,7 @@ data class UserCreated( val email: String, val name: String, val password: String, + override val received: ZonedDateTime = znow ) : Event { val toEntity: User get() = User(id, email, name, password) } @@ -76,10 +77,14 @@ data class UserCreated( data class UserUpdated( val id: String, val name: String?, - val password: String? + val password: String?, + override val received: ZonedDateTime = znow ): Event -data class UserDeleted(val id: String) : Event +data class UserDeleted( + val id: String, + override val received: ZonedDateTime = znow +) : Event // Entitites @@ -157,7 +162,7 @@ fun Route.usersRoute(processor: ActorRef, reader: Reader, scheduler: Sc } get("/notes") { val userId = userIdFromJWT(call) ?: return@get call.respond(Unauthorized, "Unauthorized") - call.respond(reader.findNotesForUser(userId).sortedBy { it.created }.map { it.toResponse() }) + call.respond(reader.findNotesForUser(userId).sortedBy { it.created }.map { it.toResponse }) } put { val userId = userIdFromJWT(call) ?: return@put call.respond(Unauthorized, "Unauthorized") diff --git a/src/main/kotlin/blog/model/model.kt b/src/main/kotlin/blog/model/model.kt index 3c6799b..4b9a424 100644 --- a/src/main/kotlin/blog/model/model.kt +++ b/src/main/kotlin/blog/model/model.kt @@ -16,21 +16,35 @@ import io.hypersistence.tsid.TSID import io.ktor.server.application.* import io.ktor.server.auth.* import io.ktor.server.auth.jwt.* +import blog.model.Constants.idNode +import blog.model.Constants.mdi +import blog.model.Constants.randm interface Request : Serializable + interface Command : Serializable -interface Event : Serializable + +interface Event : Serializable { + val received: ZonedDateTime +} + interface Entity : Serializable { val id: String } + interface Response : Serializable { val id: String } +object Constants { + val randm: SecureRandom = SecureRandom.getInstance("SHA1PRNG", "SUN") + val idNode: Int = InetAddress.getLocalHost().address[3].toInt()and(0xFF) + val mdi: MessageDigest = MessageDigest.getInstance("SHA-256") +} + object Hasher { - private val md = MessageDigest.getInstance("SHA-256") private fun toHex(ba: ByteArray) = ba.joinToString(separator = "") { String.format(Locale.US, "%02x", it) } - fun hash(s: String): String = toHex(md.digest(s.toByteArray(StandardCharsets.UTF_8))) + fun hash(s: String): String = toHex(mdi.digest(s.toByteArray(StandardCharsets.UTF_8))) } val String.hashed: String get() = Hasher.hash(this) val String.gravatar: String get() = this.trim().lowercase().hashed @@ -44,10 +58,7 @@ val inow: Instant get() = Instant.now() val znow: ZonedDateTime get() = inow.atZone(CET) val now: LocalDateTime get() = LocalDateTime.ofInstant(inow, CET) -private val idFactory = TSID.Factory.builder() - .withRandom(SecureRandom.getInstance("SHA1PRNG", "SUN")) - .withNodeBits(8) - .withNode(InetAddress.getLocalHost().address[3].toInt()and(0xFF)).build() +private val idFactory = TSID.Factory.builder().withRandom(randm).withNodeBits(8).withNode(idNode).build() val nextTSID: TSID get() = idFactory.generate() val nextId: String get() = nextTSID.toString() diff --git a/src/main/kotlin/blog/read/Reader.kt b/src/main/kotlin/blog/read/Reader.kt index 9c9c8d2..ce8fa68 100644 --- a/src/main/kotlin/blog/read/Reader.kt +++ b/src/main/kotlin/blog/read/Reader.kt @@ -73,6 +73,7 @@ class Reader( is TagCreated -> tags[e.id] = e.toEntity else -> logger.warn("could not processEvent {}", e) } + logger.info("processEvent: $e") } companion object { diff --git a/src/main/kotlin/blog/write/Processor.kt b/src/main/kotlin/blog/write/Processor.kt index c5dab43..6fb8b99 100644 --- a/src/main/kotlin/blog/write/Processor.kt +++ b/src/main/kotlin/blog/write/Processor.kt @@ -88,7 +88,7 @@ class Processor(pid: PersistenceId, private val reader: Reader) : EventSourcedBe Effect().none().thenReply(cmd.replyTo) { StatusReply.error("Note with id ${cmd.id} not found for user with id ${cmd.user}") } } else { cmd.toEvent.let { - Effect().persist(it).thenReply(cmd.replyTo) { st -> StatusReply.success(st.findNote(it.id)?.toResponse()) } + Effect().persist(it).thenReply(cmd.replyTo) { st -> StatusReply.success(st.findNote(it.id)?.toResponse) } } } diff --git a/src/test/kotlin/blog/ApiTests.kt b/src/test/kotlin/blog/ApiTests.kt index 6d3caf2..16a29ac 100644 --- a/src/test/kotlin/blog/ApiTests.kt +++ b/src/test/kotlin/blog/ApiTests.kt @@ -163,6 +163,13 @@ class ApiTests { assertThat(response.status.value).isEqualTo(200) val ur = response.body() assertThat(ur.name).isEqualTo("Anders") + + response = client.get("http://localhost:8181/api/users/me") { + header("Authorization", "Bearer ${token.token}") + } + assertThat(response.status.value).isEqualTo(200) + val updatedUser: UserResponse = response.body() + assertThat(updatedUser.name).isEqualTo("Anders") } } diff --git a/src/test/kotlin/blog/model/NoteTests.kt b/src/test/kotlin/blog/model/NoteTests.kt index 0236d30..c7b7522 100644 --- a/src/test/kotlin/blog/model/NoteTests.kt +++ b/src/test/kotlin/blog/model/NoteTests.kt @@ -35,6 +35,8 @@ class NoteTests { assertThat(nc.user).isEqualTo(userId) assertThat(nc.title).isEqualTo("title") assertThat(nc.body).isEqualTo("body") + assertThat(nc.received).isNotNull + assertThat(nc.received).isBefore(znow) val note = nc.toEntity assertThat(note.id).isNotNull() diff --git a/src/test/kotlin/blog/model/StateTests.kt b/src/test/kotlin/blog/model/StateTests.kt index 9838fba..1c697ae 100644 --- a/src/test/kotlin/blog/model/StateTests.kt +++ b/src/test/kotlin/blog/model/StateTests.kt @@ -2,7 +2,6 @@ package blog.model import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test -import java.time.LocalDateTime class StateTests { @@ -75,7 +74,7 @@ class StateTests { val state = State() val user = User(nextId, "test@tester.nl", "Tester", pw) val state2 = state.save(user) - val task = Task(nextId, user.id, "Test", "test", "Tasking, 1.. 2..", LocalDateTime.now().plusHours(1)) + val task = Task(nextId, user.id, "Test", "test", "Tasking, 1.. 2..", znow.plusHours(1)) val state3 = state2.save(task) assertThat(state3.taskCount()).isEqualTo(1) assertThat(state3.findTask(task.id)).isNotNull diff --git a/src/test/kotlin/blog/model/TaskTests.kt b/src/test/kotlin/blog/model/TaskTests.kt index 956dc3f..b60009f 100644 --- a/src/test/kotlin/blog/model/TaskTests.kt +++ b/src/test/kotlin/blog/model/TaskTests.kt @@ -5,6 +5,7 @@ import akka.pattern.StatusReply import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import java.time.LocalDateTime +import java.time.ZonedDateTime import java.util.UUID import io.hypersistence.tsid.TSID @@ -26,21 +27,21 @@ class TaskTests { assertThat(ct.id).isNotNull assertThat(ct.title).isEqualTo("title").isEqualTo(ctr.title) assertThat(ct.body).isEqualTo("body").isEqualTo(ctr.body) - assertThat(ct.due).isAfter(LocalDateTime.now()).isEqualTo(ctr.due) + assertThat(ct.due).isAfter(znow).isEqualTo(ctr.due.atZone(CET)) assertThat(ct.user).isEqualTo(userId) val tc = ct.toEvent assertThat(tc.id).isEqualTo(ct.id) assertThat(tc.title).isEqualTo("title").isEqualTo(ct.title).isEqualTo(ctr.title) assertThat(tc.body).isEqualTo("body").isEqualTo(ct.body).isEqualTo(ctr.body) - assertThat(tc.due).isEqualTo(ct.due).isEqualTo(ctr.due).isAfter(LocalDateTime.now()) + assertThat(tc.due).isEqualTo(ct.due).isEqualTo(ctr.due.atZone(CET)).isAfter(znow) assertThat(tc.user).isEqualTo(userId).isEqualTo(ct.user) val task = tc.toEntity assertThat(task.id).isEqualTo(tc.id) assertThat(task.title).isEqualTo("title").isEqualTo(tc.title).isEqualTo(ct.title).isEqualTo(ctr.title) assertThat(task.body).isEqualTo("body").isEqualTo(tc.body).isEqualTo(ct.body).isEqualTo(ctr.body) - assertThat(task.due).isEqualTo(tc.due).isEqualTo(ct.due).isEqualTo(ctr.due).isAfter(LocalDateTime.now()) + assertThat(task.due).isEqualTo(tc.due).isEqualTo(ct.due).isEqualTo(ctr.due.atZone(CET)).isAfter(znow) assertThat(task.user).isEqualTo(userId).isEqualTo(tc.user).isEqualTo(ct.user) val res = task.toResponse @@ -60,10 +61,10 @@ class TaskTests { assertThat(ut.user).isEqualTo(userId) assertThat(ut.title).isEqualTo("new title") assertThat(ut.body).isEqualTo("new body") - assertThat(ut.due).isEqualTo(utr.due).isAfter(LocalDateTime.now()) + assertThat(ut.due).isEqualTo(utr.due?.atZone(CET)).isAfter(znow) assertThat(ut.status).isEqualTo(TaskStatus.DOING) - val task = Task(taskId, userId, "title", "title".slug, "body", now.plusDays(1), TaskStatus.REVIEW) + val task = Task(taskId, userId, "title", "title".slug, "body", znow.plusDays(1), TaskStatus.REVIEW) var updated = task.update(ut.toEvent) assertThat(updated.title).isEqualTo("new title") assertThat(updated.body).isEqualTo("new body") @@ -80,7 +81,7 @@ class TaskTests { assertThat(updated.due).isEqualTo(task.due) assertThat(updated.status).isEqualTo(task.status) - val due = LocalDateTime.now().plusHours(1) + val due = znow.plusHours(1) updated = task.update(TaskUpdated(userId, taskId, null, null, due, null)) assertThat(updated.body).isEqualTo(task.body) assertThat(updated.title).isEqualTo(task.title) @@ -99,4 +100,19 @@ class TaskTests { assertThat(updated.due).isEqualTo(task.due) assertThat(updated.status).isEqualTo(task.status) } + + @Test + fun zonedDateTimeParsing() { + var zonedDateTime: ZonedDateTime = ZonedDateTime.parse("2022-11-03T17:35:00Z") + assertThat(zonedDateTime).isNotNull + assertThat(zonedDateTime.year).isEqualTo(2022) + assertThat(zonedDateTime.hour).isEqualTo(17) + + val localDateTime = LocalDateTime.parse("2023-10-02T16:24:11") + assertThat(localDateTime).isNotNull + zonedDateTime = localDateTime.atZone(CET) + assertThat(zonedDateTime).isNotNull + assertThat(zonedDateTime.dayOfMonth).isEqualTo(2) + assertThat(zonedDateTime.hour).isEqualTo(16) + } } diff --git a/src/test/kotlin/blog/model/UserTests.kt b/src/test/kotlin/blog/model/UserTests.kt index 36f8b24..e886be6 100644 --- a/src/test/kotlin/blog/model/UserTests.kt +++ b/src/test/kotlin/blog/model/UserTests.kt @@ -51,6 +51,8 @@ class UserTests { assertThat(u.name).isEqualTo(uc.name) assertThat(u.password).isEqualTo(uc.password) assertThat(u.gravatar).isEqualTo("6c8e85364ba2cae4fc908189bee6fa566f148957c42dd778c1cd6e0af03cb0aa") + assertThat(u.hashCode()).isEqualTo(uc.id.hashCode()) + assertThat(u.joined).isBeforeOrEqualTo(znow) } @Test diff --git a/src/test/kotlin/blog/read/ReaderTests.kt b/src/test/kotlin/blog/read/ReaderTests.kt index bae54f4..6daaa6c 100644 --- a/src/test/kotlin/blog/read/ReaderTests.kt +++ b/src/test/kotlin/blog/read/ReaderTests.kt @@ -8,8 +8,8 @@ import blog.model.UserCreated import blog.model.nextId import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test -import java.time.LocalDateTime import blog.model.Task +import blog.model.znow class ReaderTests { @@ -47,7 +47,7 @@ class ReaderTests { fun `new task`() { val reader = Reader() val taskId = nextId - reader.processEvent(TaskCreated(taskId, userId, "title", "body", LocalDateTime.now().plusHours(4))) + reader.processEvent(TaskCreated(taskId, userId, "title", "body", znow.plusHours(4))) assertThat(reader.allTasks()).hasSize(1) assertThat(reader.findTasksForUser(userId)).hasSize(1) assertThat(reader.find(taskId)).isNotNull @@ -63,7 +63,7 @@ class ReaderTests { assertThat(reader.allNotes()).hasSize(1) assertThat(reader.findNotesForUser(userId)).hasSize(1) - reader.processEvent(TaskCreated(nextId, userId, "title", "body", LocalDateTime.now().plusHours(4))) + reader.processEvent(TaskCreated(nextId, userId, "title", "body", znow.plusHours(4))) assertThat(reader.allTasks()).hasSize(1) assertThat(reader.findTasksForUser(userId)).hasSize(1) }