Skip to content

Commit 89324d3

Browse files
committed
better validation, comments on notes, and much more
1 parent 9d136e2 commit 89324d3

22 files changed

+295
-137
lines changed

Diff for: build.gradle.kts

+1-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ dependencies {
4343
implementation("org.scala-lang:scala-library:2.13.12")
4444
implementation("io.hypersistence:hypersistence-tsid:2.1.1")
4545

46-
implementation("io.netty:netty-all:4.1.105.Final")
46+
implementation("io.netty:netty-all:4.1.106.Final")
4747
implementation("org.hibernate:hibernate-validator:8.0.1.Final")
4848
implementation("org.glassfish.expressly:expressly:5.0.0")
4949
implementation("org.owasp.encoder:encoder:1.2.3")

Diff for: src/main/java/blog/validator/CheckTSID.java

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package blog.validator;
2+
3+
import jakarta.validation.Constraint;
4+
import jakarta.validation.Payload;
5+
6+
import java.lang.annotation.Documented;
7+
import java.lang.annotation.Repeatable;
8+
import java.lang.annotation.Retention;
9+
import java.lang.annotation.Target;
10+
11+
import static java.lang.annotation.ElementType.*;
12+
import static java.lang.annotation.RetentionPolicy.RUNTIME;
13+
14+
@Target({ FIELD, METHOD, PARAMETER, ANNOTATION_TYPE, TYPE_USE })
15+
@Retention(RUNTIME)
16+
@Constraint(validatedBy = CheckTSIDValidator.class)
17+
@Documented
18+
@Repeatable(CheckTSID.List.class)
19+
public @interface CheckTSID {
20+
String message() default "{jakarta.validation.constraints.NotBlank.message}";
21+
Class<?>[] groups() default { };
22+
Class<? extends Payload>[] payload() default { };
23+
24+
@Target({ FIELD, METHOD, PARAMETER, ANNOTATION_TYPE })
25+
@Retention(RUNTIME)
26+
@Documented
27+
@interface List {
28+
CheckTSID[] value();
29+
}
30+
}

Diff for: src/main/java/blog/validator/CheckTSIDValidator.java

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package blog.validator;
2+
3+
import io.hypersistence.tsid.TSID;
4+
import jakarta.validation.ConstraintValidator;
5+
import jakarta.validation.ConstraintValidatorContext;
6+
7+
import java.time.Instant;
8+
import java.util.Objects;
9+
10+
public final class CheckTSIDValidator implements ConstraintValidator<CheckTSID, String> {
11+
12+
@Override
13+
public void initialize(final CheckTSID constraintAnnotation) {
14+
ConstraintValidator.super.initialize(constraintAnnotation);
15+
}
16+
17+
@Override
18+
public boolean isValid(final String value, final ConstraintValidatorContext context) {
19+
Objects.requireNonNull(context);
20+
return TSID.isValid(value) && TSID.from(value).getInstant().isBefore(Instant.now());
21+
}
22+
}

Diff for: src/main/java/blog/validator/CheckTsidDef.java

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package blog.validator;
2+
3+
import org.hibernate.validator.cfg.ConstraintDef;
4+
5+
public class CheckTsidDef extends ConstraintDef<CheckTsidDef, CheckTSID> {
6+
public CheckTsidDef() {
7+
super(CheckTSID.class);
8+
}
9+
}

Diff for: src/main/kotlin/blog/Application.kt

+31-8
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import akka.actor.typed.Scheduler
77
import akka.actor.typed.javadsl.AskPattern.ask
88
import akka.actor.typed.javadsl.Behaviors
99
import blog.model.Command
10+
import blog.model.CreateCommentRequest
1011
import blog.model.CreateNoteRequest
1112
import blog.model.CreateTaskRequest
1213
import blog.model.DeleteNote
14+
import blog.model.DeleteTask
1315
import blog.model.LoginRequest
1416
import blog.model.RegisterUserRequest
1517
import blog.model.UpdateNoteRequest
@@ -34,11 +36,13 @@ import reactor.core.publisher.Mono.justOrEmpty
3436
import reactor.kotlin.core.publisher.toFlux
3537
import java.net.URI
3638
import java.time.Duration
39+
import java.util.Locale
3740

3841
@SpringBootApplication
3942
class Application
4043

4144
fun main() {
45+
locale()
4246
ActorSystem.create(Server.create(), "system")
4347
}
4448

@@ -59,7 +63,7 @@ fun beans(processor: ActorRef<Command>, system: ActorSystem<Void>, reader: Reade
5963
}
6064

6165
fun ServerRequest.monoPathVar(s: String): Mono<String> = justOrEmpty(s).mapNotNull { runCatching { this.pathVariable(it) }.getOrNull() }
62-
fun ServerRequest.pathAsTSID(s: String): Mono<TSID> = this.monoPathVar(s).map { it.toLong() }.map { it.toTSID() }
66+
fun ServerRequest.pathAsTSID(s: String): Mono<TSID> = this.monoPathVar(s).map { it.toTSID() }
6367

6468
fun routes(handler: ApiHandler): RouterFunction<ServerResponse> =
6569
router {
@@ -73,13 +77,15 @@ fun routes(handler: ApiHandler): RouterFunction<ServerResponse> =
7377
GET("/note/{id}", handler::findNote)
7478
PUT("/note", handler::updateNote)
7579
DELETE("/note/{id}", handler::deleteNote)
80+
POST("/note/{id}/comment", handler::comment)
7681

7782
GET("/users") { _ -> handler.findAll() }
7883
GET("/user/id/{id}", handler::findById)
7984
GET("/user/email/{email}", handler::findByEmail)
8085

8186
POST("/tasks", handler::createTask)
8287
PUT("/tasks", handler::updateTask)
88+
DELETE("/tasks/{id}", handler::deleteTask)
8389
}
8490
}
8591

@@ -108,7 +114,7 @@ class ApiHandler(private val scheduler: Scheduler, private val processor: ActorR
108114
.switchIfEmpty(forbidden)
109115

110116
fun findById(req: ServerRequest): Mono<ServerResponse> = req.pathAsTSID("id")
111-
.flatMap { justOrEmpty(reader.findUserById(it)) }
117+
.flatMap { justOrEmpty(reader.findUser(it)) }
112118
.map { it.toResponse(reader) }
113119
.flatMap { ServerResponse.ok().bodyValue(it) }
114120
.switchIfEmpty(notFound)
@@ -122,7 +128,7 @@ class ApiHandler(private val scheduler: Scheduler, private val processor: ActorR
122128
fun findAll(): Mono<ServerResponse> = ok.contentType(MediaType.APPLICATION_JSON).body(reader.allUsers().toFlux(), UserResponse::class.java)
123129

124130
fun createNote(req: ServerRequest): Mono<ServerResponse> = loggedin(req)
125-
.flatMap { principal -> req.bodyToMono(CreateNoteRequest::class.java).map { it.copy(user = principal.id.toLong()) } }
131+
.flatMap { principal -> req.bodyToMono(CreateNoteRequest::class.java).map { it.copy(user = principal.id.toString()) } }
126132
.flatMap { fromCompletionStage(ask(processor, { rt -> it.toCommand(rt) }, timeout, scheduler)) }
127133
.flatMap {
128134
if (it.isSuccess) {
@@ -133,7 +139,7 @@ class ApiHandler(private val scheduler: Scheduler, private val processor: ActorR
133139
}.switchIfEmpty(unauthorized)
134140

135141
fun updateNote(req: ServerRequest): Mono<ServerResponse> = loggedin(req)
136-
.flatMap { principal -> req.bodyToMono(UpdateNoteRequest::class.java).map { it.copy(user = principal.id) } }
142+
.flatMap { principal -> req.bodyToMono(UpdateNoteRequest::class.java).map { it.copy(user = principal.id.toString()) } }
137143
.flatMap { fromCompletionStage(ask(processor, { rt -> it.toCommand(rt) }, timeout, scheduler)) }
138144
.flatMap { if (it.isSuccess) ok.bodyValue(it.value) else badreq(it.error.message) }
139145
.switchIfEmpty(unauthorized)
@@ -148,17 +154,34 @@ class ApiHandler(private val scheduler: Scheduler, private val processor: ActorR
148154
fun findNote(req: ServerRequest): Mono<ServerResponse> =
149155
req.pathAsTSID("id")
150156
.flatMap { justOrEmpty(reader.findNote(it)) }
151-
.flatMap { ok.bodyValue(it) }
157+
.flatMap { ok.bodyValue(it.toResponse()) }
152158
.switchIfEmpty(notFound)
153159

154160
fun createTask(req: ServerRequest): Mono<ServerResponse> = loggedin(req)
155-
.flatMap { principal -> req.bodyToMono(CreateTaskRequest::class.java).map { it.copy(user = principal.id) } }
161+
.flatMap { principal -> req.bodyToMono(CreateTaskRequest::class.java).map { it.copy(user = principal.id.toString()) } }
156162
.flatMap { ServerResponse.unprocessableEntity().build() }
157163

158164
fun updateTask(req: ServerRequest): Mono<ServerResponse> = loggedin(req)
159-
.flatMap { principal -> req.bodyToMono(UpdateTaskRequest::class.java).map { it.copy(user = principal.id) } }
160-
.flatMap { ServerResponse.unprocessableEntity().build() }
165+
.flatMap { principal -> req.bodyToMono(UpdateTaskRequest::class.java).map { it.copy(user = principal.id.toString()) } }
166+
.flatMap { fromCompletionStage(ask(processor, { rt -> it.toCommand(rt) }, timeout, scheduler)) }
167+
.flatMap { if(it.isSuccess) ok.build() else badRequest }
168+
.switchIfEmpty(unauthorized)
169+
170+
fun deleteTask(req: ServerRequest): Mono<ServerResponse> = loggedin(req)
171+
.zipWith(req.pathAsTSID("id").mapNotNull { reader.findTask(it) })
172+
.filter { tup -> tup?.t2?.user != null && tup.t2.user == tup.t1.id }
173+
.flatMap { tup -> fromCompletionStage(ask(processor, { DeleteTask(tup.t1.id, it) }, timeout, scheduler)) }
174+
.flatMap { if(it.isSuccess) ok.build() else badRequest }
175+
.switchIfEmpty(unauthorized)
161176

162177
private fun loggedin(req: ServerRequest): Mono<User> =
163178
justOrEmpty(req.headers().firstHeader("Authorization")).mapNotNull { reader.loggedin(it) }
179+
180+
fun comment(req: ServerRequest): Mono<ServerResponse> = loggedin(req)
181+
.flatMap { principal -> req.bodyToMono(CreateCommentRequest::class.java).map { it.copy(user = principal.id.toString()) } }
182+
.flatMap { fromCompletionStage(ask(processor, { rt -> it.toCommand(rt) }, timeout, scheduler)) }
183+
.flatMap { if(it.isSuccess) ok.build() else badRequest }
184+
.switchIfEmpty(unauthorized)
164185
}
186+
187+
fun locale(): Unit = Locale.setDefault(Locale.of("nl", "NL"))

Diff for: src/main/kotlin/blog/model/Comment.kt

+10-9
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,18 @@ data class Comment(
1212
val text: String,
1313
val score: Int
1414
) {
15-
fun toResponse() = CommentResponse(id.toLong(), user.toLong(), owner.toLong(), parent?.toLong(), text, score)
15+
fun toResponse() = CommentResponse(id.toString(), user.toString(), owner.toString(), parent?.toString(), text, score)
1616
}
1717

1818
data class CreateCommentRequest(
19-
val user: Long,
20-
val owner: Long,
21-
val parent: Long?,
19+
val user: String,
20+
val owner: String,
21+
val parent: String?,
2222
val text: String,
2323
val score: Int = 0
2424
) {
25-
fun toCommand(replyTo: ActorRef<StatusReply<CommentResponse>>) = CreateComment(user.toTSID(), owner.toTSID(), parent.toTSID(), text, score, replyTo)
25+
fun validate() = Validator.validate(this)
26+
fun toCommand(replyTo: ActorRef<StatusReply<CommentResponse>>) = CreateComment(user.toTSID(), owner.toTSID(), parent?.toTSID(), text, score, replyTo)
2627
}
2728

2829
data class CreateComment(
@@ -49,10 +50,10 @@ data class CommentCreated(
4950
}
5051

5152
data class CommentResponse(
52-
override val id: Long,
53-
val user: Long,
54-
val owner: Long, // Note or Task
55-
val parent: Long?,
53+
override val id: String,
54+
val user: String,
55+
val owner: String, // Note or Task
56+
val parent: String?,
5657
val text: String,
5758
val score: Int
5859
): Response

Diff for: src/main/kotlin/blog/model/Note.kt

+13-14
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package blog.model
22

3+
import akka.Done
34
import akka.actor.typed.ActorRef
45
import akka.pattern.StatusReply
56
import io.hypersistence.tsid.TSID
@@ -8,14 +9,14 @@ import org.owasp.encoder.Encode
89
import java.time.LocalDateTime
910
import java.time.ZoneId
1011

11-
data class CreateNoteRequest(val user: Long, val title: String, val body: String) {
12+
data class CreateNoteRequest(val user: String, val title: String, val body: String) {
1213
fun validate(): Set<ConstraintViolation<CreateNoteRequest>> = Validator.validate(this)
1314
fun toCommand(replyTo: ActorRef<StatusReply<NoteResponse>>) = CreateNote(user.toTSID(), title, body, replyTo)
1415
}
1516

16-
data class UpdateNoteRequest(val user: TSID, val id: TSID, val title: String?, val body: String?) {
17+
data class UpdateNoteRequest(val user: String, val id: String, val title: String?, val body: String?) {
1718
fun validate(): Set<ConstraintViolation<UpdateNoteRequest>> = Validator.validate(this)
18-
fun toCommand(rt: ActorRef<StatusReply<NoteResponse>>) = UpdateNote(user, id, title, body, rt)
19+
fun toCommand(rt: ActorRef<StatusReply<NoteResponse>>) = UpdateNote(user.toTSID(), id.toTSID(), title, body, rt)
1920
}
2021

2122
data class CreateNote(
@@ -25,42 +26,40 @@ data class CreateNote(
2526
val replyTo: ActorRef<StatusReply<NoteResponse>>,
2627
val id: TSID = nextId()
2728
) : Command {
28-
fun toEvent() = NoteEvent(id, user, Encode.forHtml(title), Encode.forHtml(body))
29+
fun toEvent() = NoteCreated(id, user, Encode.forHtml(title), Encode.forHtml(body))
2930
}
3031

3132
data class UpdateNote(val user: TSID, val id: TSID, val title: String?, val body: String?, val replyTo: ActorRef<StatusReply<NoteResponse>>): Command {
3233
fun toEvent() = NoteUpdated(id, user, title, body)
3334
}
3435

35-
data class DeleteNote(val user: TSID, val id: TSID, val rt: ActorRef<StatusReply<NoteDeletedResponse>>): Command {
36+
data class DeleteNote(val user: TSID, val id: TSID, val rt: ActorRef<StatusReply<Done>>): Command {
3637
fun toEvent() = NoteDeleted(id, user)
3738
}
3839

39-
data class NoteEvent(val id: TSID, val user: TSID, val title: String, val body: String) : Event {
40-
fun toEntity() = Note(id, user, title, body)
40+
data class NoteCreated(val id: TSID, val user: TSID, val title: String, val body: String) : Event {
41+
fun toEntity() = Note(id, user, title, slugify(title), body)
4142
}
4243

4344
data class NoteUpdated(val id: TSID, val user: TSID, val title: String?, val body: String?): Event
4445

45-
data class NoteDeleted(val id: TSID, val user: TSID): Event {
46-
fun toResponse() = NoteDeletedResponse(id.toLong())
47-
}
46+
data class NoteDeleted(val id: TSID, val user: TSID): Event
4847

4948
data class Note(
5049
override val id: TSID,
5150
val user: TSID,
5251
val title: String,
52+
val slug: String,
5353
val body: String
5454
): Entity {
55+
constructor(id: TSID, user: TSID, title: String, body: String): this(id, user, title, slugify(title), body)
5556
fun update(nu: NoteUpdated): Note = this.copy(title = nu.title ?: this.title, body = nu.body ?: this.body)
56-
fun toResponse() = NoteResponse(id.toLong(), LocalDateTime.ofInstant(id.instant, ZoneId.of("CET")), title, body)
57+
fun toResponse() = NoteResponse(id.toString(), LocalDateTime.ofInstant(id.instant, ZoneId.of("CET")), title, body)
5758
}
5859

5960
data class NoteResponse(
60-
override val id: Long,
61+
override val id: String,
6162
val created: LocalDateTime,
6263
val title: String,
6364
val body: String
6465
) : Response
65-
66-
data class NoteDeletedResponse(override val id: Long): Response

Diff for: src/main/kotlin/blog/model/State.kt

+11-5
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,18 @@ package blog.model
33
import io.hypersistence.tsid.TSID
44

55
data class State(
6-
private val users: Map<TSID, User> = mapOf(),
7-
private val notes: Map<TSID, Note> = mapOf(),
8-
private val tasks: Map<TSID, Task> = mapOf(),
6+
private val users: Map<TSID, User> = mapOf(),
7+
private val notes: Map<TSID, Note> = mapOf(),
8+
private val tasks: Map<TSID, Task> = mapOf(),
99
private val sesss: Map<String, Session> = mapOf(),
10-
private val recovered: Boolean = false
10+
private val comms: Map<TSID, Comment> = mapOf(),
11+
private val recovered: Boolean = false
1112
) {
1213
fun save(u: User) : State = this.copy(users = this.users.minus(u.id).plus(u.id to u))
1314
fun findUserById(id: TSID) : User? = users[id]
1415
fun findUserByEmail(email: String): User? = users.values.find { it.email == email }
1516
fun allUsers() : List<User> = users.values.toList()
1617
fun userCount() : Int = users.size
17-
fun delete(u: User) : State = this.copy(users = this.users.minus(u.id))
1818

1919
fun save(n: Note) : State = this.copy(notes = this.notes.minus(n.id).plus(n.id to n))
2020
fun findNoteById(id: TSID) : Note? = notes[id]
@@ -37,5 +37,11 @@ data class State(
3737
}
3838
fun loggedin(session: String): User? = sesss[session]?.user
3939

40+
fun save(c: Comment): State = this.copy(comms = this.comms.plus(c.id to c))
41+
fun findCommentsForUser(id: TSID): List<Comment> = comms.values.filter { it.user == id }
42+
fun findCommentsForNote(id: TSID): List<Comment> = comms.values.filter { it.owner == id }
43+
fun findComment(id: TSID): Comment? = comms[id]
44+
fun commentCount() = comms.size
45+
4046
fun hasRecovered(): State = this.copy(recovered = true)
4147
}

0 commit comments

Comments
 (0)