From 1fb0e3903850010d847855aaac230ba87dd27279 Mon Sep 17 00:00:00 2001 From: Fabrice Lecomte Date: Tue, 27 Aug 2019 22:24:38 +0200 Subject: [PATCH] feature #7: Add routes for comment article --- src/main/kotlin/fr/dcproject/Application.kt | 12 ++ src/main/kotlin/fr/dcproject/Module.kt | 6 + .../kotlin/fr/dcproject/entity/Comment.kt | 17 +++ src/main/kotlin/fr/dcproject/entity/Extra.kt | 2 +- .../kotlin/fr/dcproject/repository/Comment.kt | 139 ++++++++++++++++++ .../kotlin/fr/dcproject/routes/Comment.kt | 45 ++++++ .../fr/dcproject/routes/CommentArticle.kt | 55 +++++++ .../dcproject/security/voter/ArticleVoter.kt | 10 +- .../dcproject/security/voter/CommentVoter.kt | 38 +++++ .../sql/functions/comment/edit_comment.sql | 11 +- .../functions/comment/find_comment_by_id.sql | 4 +- src/test/resources/feature/comment.feature | 35 +++++ 12 files changed, 364 insertions(+), 10 deletions(-) create mode 100644 src/main/kotlin/fr/dcproject/entity/Comment.kt create mode 100644 src/main/kotlin/fr/dcproject/repository/Comment.kt create mode 100644 src/main/kotlin/fr/dcproject/routes/Comment.kt create mode 100644 src/main/kotlin/fr/dcproject/routes/CommentArticle.kt create mode 100644 src/main/kotlin/fr/dcproject/security/voter/CommentVoter.kt create mode 100644 src/test/resources/feature/comment.feature diff --git a/src/main/kotlin/fr/dcproject/Application.kt b/src/main/kotlin/fr/dcproject/Application.kt index 7411d89..e80e367 100644 --- a/src/main/kotlin/fr/dcproject/Application.kt +++ b/src/main/kotlin/fr/dcproject/Application.kt @@ -18,6 +18,7 @@ import fr.dcproject.security.voter.CitizenVoter import fr.dcproject.security.voter.CommentVoter import fr.postgresjson.migration.Migrations import io.ktor.application.Application +import io.ktor.application.ApplicationCall import io.ktor.application.call import io.ktor.application.install import io.ktor.auth.Authentication @@ -39,6 +40,7 @@ import java.util.* import java.util.concurrent.CompletionException import fr.dcproject.repository.Article as RepositoryArticle import fr.dcproject.repository.Citizen as RepositoryCitizen +import fr.dcproject.repository.CommentGeneric as CommentGenericRepository import fr.dcproject.repository.Constitution as RepositoryConstitution import fr.dcproject.repository.User as UserRepository @@ -90,6 +92,14 @@ fun Application.module() { } } + convert { + decode { values, _ -> + val id = values.singleOrNull()?.let { UUID.fromString(it) } + ?: throw InternalError("Cannot convert $values to UUID") + get().findById(id) ?: throw InternalError("Comment $values not found") + } + } + convert { decode { values, _ -> val id = values.singleOrNull()?.let { UUID.fromString(it) } @@ -154,6 +164,8 @@ fun Application.module() { constitution(get()) followArticle(get()) followConstitution(get()) + comment(get()) + commentArticle(get()) } } diff --git a/src/main/kotlin/fr/dcproject/Module.kt b/src/main/kotlin/fr/dcproject/Module.kt index 1dd834a..1798b92 100644 --- a/src/main/kotlin/fr/dcproject/Module.kt +++ b/src/main/kotlin/fr/dcproject/Module.kt @@ -7,6 +7,8 @@ import io.ktor.util.KtorExperimentalAPI import org.koin.dsl.module import fr.dcproject.repository.Article as ArticleRepository import fr.dcproject.repository.Citizen as CitizenRepository +import fr.dcproject.repository.CommentArticle as CommentArticleRepository +import fr.dcproject.repository.CommentGeneric as CommentGenericRepository import fr.dcproject.repository.Constitution as ConstitutionRepository import fr.dcproject.repository.FollowArticle as FollowArticleRepository import fr.dcproject.repository.FollowConstitution as FollowConstitutionRepository @@ -33,6 +35,10 @@ val Module = module { single { ConstitutionRepository(get()) } single { FollowArticleRepository(get()) } single { FollowConstitutionRepository(get()) } + single { CommentGenericRepository(get()) } + single { CommentArticleRepository(get()) } + // TODO implment constitution +// single { CommentConstitutionRepository(get()) } single { Migrations(connection = get(), directory = config.sqlFiles) } } diff --git a/src/main/kotlin/fr/dcproject/entity/Comment.kt b/src/main/kotlin/fr/dcproject/entity/Comment.kt new file mode 100644 index 0000000..900df6e --- /dev/null +++ b/src/main/kotlin/fr/dcproject/entity/Comment.kt @@ -0,0 +1,17 @@ +package fr.dcproject.entity + +import fr.postgresjson.entity.EntityUpdatedAt +import fr.postgresjson.entity.EntityUpdatedAtImp +import fr.postgresjson.entity.UuidEntity +import java.util.* + +open class Comment ( + id: UUID = UUID.randomUUID(), + createdBy: Citizen, + override var target: T, + var content: String, + var responses: List>? = null, + var parent: Comment? = null, + var parentsIds: List? = null +): Extra(id, createdBy), + EntityUpdatedAt by EntityUpdatedAtImp() diff --git a/src/main/kotlin/fr/dcproject/entity/Extra.kt b/src/main/kotlin/fr/dcproject/entity/Extra.kt index 9d5bd25..e5743aa 100644 --- a/src/main/kotlin/fr/dcproject/entity/Extra.kt +++ b/src/main/kotlin/fr/dcproject/entity/Extra.kt @@ -10,7 +10,7 @@ interface ExtraI >: var target: T } -abstract class Extra>( +abstract class Extra( id: UUID? = UUID.randomUUID(), createdBy: Citizen ): diff --git a/src/main/kotlin/fr/dcproject/repository/Comment.kt b/src/main/kotlin/fr/dcproject/repository/Comment.kt new file mode 100644 index 0000000..5ccc54b --- /dev/null +++ b/src/main/kotlin/fr/dcproject/repository/Comment.kt @@ -0,0 +1,139 @@ +package fr.dcproject.repository + +import fr.postgresjson.connexion.Paginated +import fr.postgresjson.connexion.Requester +import fr.postgresjson.entity.UuidEntity +import fr.postgresjson.repository.RepositoryI +import java.util.* +import kotlin.reflect.KClass +import fr.dcproject.entity.Article as ArticleEntity +import fr.dcproject.entity.Citizen as CitizenEntity +import fr.dcproject.entity.Comment as CommentEntity +import fr.dcproject.entity.Constitution as ConstitutionEntity + +open class Comment (override var requester: Requester): RepositoryI> { + override val entityName = CommentEntity::class as KClass> + + open fun findByCitizen( + citizen: CitizenEntity, + page: Int = 1, + limit: Int = 50 + ): Paginated> = + findByCitizen(citizen.id ?: error("The citizen must have an id"), page, limit) + + open fun findByCitizen( + citizenId: UUID, + page: Int = 1, + limit: Int = 50 + ): Paginated> { + return requester.run { + getFunction("find_comments_by_citizen") + .select(page, limit, + "created_by_id" to citizenId + ) + } + } + + open fun findByParent( + parent: CommentEntity, + page: Int = 1, + limit: Int = 50 + ): Paginated> { + return findByParent(parent.id ?: error("comment must have an ID"), page, limit) + } + + open fun findByParent( + parentId: UUID, + page: Int = 1, + limit: Int = 50 + ): Paginated> { + return requester.run { + getFunction("find_comments_by_parent") + .select(page, limit, + "parent_id" to parentId + ) + } + } + + open fun findByTarget( + target: UuidEntity, + page: Int = 1, + limit: Int = 50 + ): Paginated> { + return findByTarget(target.id ?: error("comment must have an ID"), page, limit) + } + + open fun findByTarget( + targetId: UUID, + page: Int = 1, + limit: Int = 50 + ): Paginated> { + return requester.run { + getFunction("find_comments_by_target") + .select(page, limit, + "target_id" to targetId + ) + } + } + + fun comment(comment: CommentEntity) { + val reference = comment.target::class.simpleName!!.toLowerCase() + requester + .getFunction("comment") + .sendQuery( + "reference" to reference, + "target_id" to comment.target.id, + "created_by_id" to comment.createdBy?.id, + "content" to comment.content + ) + } + + fun edit(comment: CommentEntity) { + val reference = comment.target::class.simpleName!!.toLowerCase() + requester + .getFunction("edit_comment") + .sendQuery( + "reference" to reference, + "id" to comment.target.id, + "content" to comment.content + ) + } +} + +class CommentGeneric (requester: Requester): Comment(requester) { + fun findById(id: UUID): CommentEntity? { + return requester + .getFunction("find_comment_by_id") + .selectOne(mapOf("id" to id)) + } +} + +class CommentArticle (requester: Requester): Comment(requester) { + override fun findByCitizen( + citizenId: UUID, + page: Int, + limit: Int + ): Paginated> { + return requester.run { + getFunction("find_comments_article_by_citizen") + .select(page, limit, + "created_by_id" to citizenId + ) + } + } +} + +class CommentConstitution (requester: Requester): Comment(requester) { + override fun findByCitizen( + citizenId: UUID, + page: Int, + limit: Int + ): Paginated> { + return requester.run { + getFunction("find_comments_constitution_by_citizen") + .select(page, limit, + "created_by_id" to citizenId + ) + } + } +} diff --git a/src/main/kotlin/fr/dcproject/routes/Comment.kt b/src/main/kotlin/fr/dcproject/routes/Comment.kt new file mode 100644 index 0000000..1ea9324 --- /dev/null +++ b/src/main/kotlin/fr/dcproject/routes/Comment.kt @@ -0,0 +1,45 @@ +package fr.dcproject.routes + +import fr.dcproject.security.voter.CommentVoter.Action.UPDATE +import fr.dcproject.security.voter.CommentVoter.Action.VIEW +import fr.dcproject.security.voter.assertCan +import fr.postgresjson.entity.UuidEntity +import io.ktor.application.call +import io.ktor.http.HttpStatusCode +import io.ktor.locations.KtorExperimentalLocationsAPI +import io.ktor.locations.Location +import io.ktor.locations.get +import io.ktor.locations.put +import io.ktor.request.receiveText +import io.ktor.response.respond +import io.ktor.routing.Route +import java.util.* +import fr.dcproject.entity.Comment as CommentEntity +import fr.dcproject.repository.CommentGeneric as CommentRepository + +typealias CommentEntityGeneric = CommentEntity +@KtorExperimentalLocationsAPI +object CommentPaths { + // TODO: change UUID by entity converter + @Location("/comments/{comment}") class CommentRequest(val comment: UUID) +} + +@KtorExperimentalLocationsAPI +fun Route.comment(repo: CommentRepository) { + get { + val comment = repo.findById(it.comment)!! + assertCan(VIEW, comment) + + call.respond(HttpStatusCode.OK, comment) + } + + put { + val comment = repo.findById(it.comment)!! + assertCan(UPDATE,comment) + + comment.content = call.receiveText() + repo.edit(comment as fr.dcproject.entity.Comment) + + call.respond(HttpStatusCode.OK, comment) + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/dcproject/routes/CommentArticle.kt b/src/main/kotlin/fr/dcproject/routes/CommentArticle.kt new file mode 100644 index 0000000..ffc6b46 --- /dev/null +++ b/src/main/kotlin/fr/dcproject/routes/CommentArticle.kt @@ -0,0 +1,55 @@ +package fr.dcproject.routes + +import fr.dcproject.citizen +import fr.dcproject.entity.Citizen +import fr.dcproject.security.voter.CommentVoter.Action.CREATE +import fr.dcproject.security.voter.CommentVoter.Action.VIEW +import fr.dcproject.security.voter.assertCan +import io.ktor.application.call +import io.ktor.http.HttpStatusCode +import io.ktor.locations.KtorExperimentalLocationsAPI +import io.ktor.locations.Location +import io.ktor.locations.get +import io.ktor.locations.post +import io.ktor.request.receive +import io.ktor.response.respond +import io.ktor.routing.Route +import fr.dcproject.entity.Article as ArticleEntity +import fr.dcproject.entity.Comment as CommentEntity +import fr.dcproject.repository.CommentArticle as CommentArticleRepository + +@KtorExperimentalLocationsAPI +object CommentArticlePaths { + @Location("/articles/{article}/comments") class ArticleCommentRequest(val article: ArticleEntity) + @Location("/citizens/{citizen}/comments/articles") class CitizenCommentArticleRequest(val citizen: Citizen) +} + +@KtorExperimentalLocationsAPI +fun Route.commentArticle(repo: CommentArticleRepository) { + get { + assertCan(VIEW, it.article) + + val comment = repo.findByTarget(it.article) + + call.respond(HttpStatusCode.OK, comment) + } + + post { + assertCan(CREATE, it.article) + + val content = call.receive() + val comment = CommentEntity( + target = it.article, + createdBy = citizen, + content = content + ) + repo.comment(comment) + + call.respond(HttpStatusCode.Created, comment) + } + + get { + val comments = repo.findByCitizen(it.citizen) + call.respond(comments) + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/dcproject/security/voter/ArticleVoter.kt b/src/main/kotlin/fr/dcproject/security/voter/ArticleVoter.kt index 65d5220..60e4da1 100644 --- a/src/main/kotlin/fr/dcproject/security/voter/ArticleVoter.kt +++ b/src/main/kotlin/fr/dcproject/security/voter/ArticleVoter.kt @@ -13,7 +13,7 @@ class ArticleVoter: Voter { } override fun supports(action: ActionI, call: ApplicationCall, subject: Any?): Boolean { - return action is Action && subject is Article? + return (action is Action || action is CommentVoter.Action) && subject is Article? } override fun vote(action: ActionI, call: ApplicationCall, subject: Any?): Vote { @@ -26,6 +26,14 @@ class ArticleVoter: Voter { return Vote.GRANTED } + if (action == CommentVoter.Action.CREATE) { + return Vote.GRANTED + } + + if (action == CommentVoter.Action.VIEW) { + return Vote.GRANTED + } + if (action == Action.DELETE && user is User && subject is Article && subject.createdBy?.userId == user.id) { return Vote.GRANTED } diff --git a/src/main/kotlin/fr/dcproject/security/voter/CommentVoter.kt b/src/main/kotlin/fr/dcproject/security/voter/CommentVoter.kt new file mode 100644 index 0000000..b4011d9 --- /dev/null +++ b/src/main/kotlin/fr/dcproject/security/voter/CommentVoter.kt @@ -0,0 +1,38 @@ +package fr.dcproject.security.voter + +import fr.dcproject.entity.Comment +import io.ktor.application.ApplicationCall + +class CommentVoter: Voter { + enum class Action: ActionI { + CREATE, + UPDATE, + VIEW, + DELETE + } + + override fun supports(action: ActionI, call: ApplicationCall, subject: Any?): Boolean { + return action is Action && subject is Comment<*>? + } + + override fun vote(action: ActionI, call: ApplicationCall, subject: Any?): Vote { + val user = call.user + if (action == Action.CREATE && user != null) { + return Vote.GRANTED + } + + if (action == Action.VIEW) { + return Vote.GRANTED + } + + if (action == Action.UPDATE && user != null && subject is Comment<*> && user.id == subject.createdBy?.userId) { + return Vote.GRANTED + } + + if (action == Action.DELETE) { + return Vote.DENIED + } + + return Vote.ABSTAIN + } +} diff --git a/src/main/resources/sql/functions/comment/edit_comment.sql b/src/main/resources/sql/functions/comment/edit_comment.sql index f747650..b6a2a52 100644 --- a/src/main/resources/sql/functions/comment/edit_comment.sql +++ b/src/main/resources/sql/functions/comment/edit_comment.sql @@ -1,20 +1,17 @@ -create or replace function edit_comment(reference regclass, id uuid, content text) returns void +create or replace function edit_comment(reference regclass, _id uuid, _content text) returns void language plpgsql as $$ -declare - _id alias for id; - _content alias for content; begin if reference = 'article'::regclass then update comment_on_article c set - content = _content + "content" = _content where c.id = _id; elseif reference = 'constitution'::regclass then update comment_on_constitution c set - content = _content + "content" = _content where c.id = _id; end if; end; $$; --- drop function if exists edit_comment(regclass, uuid, uuid, text, uuid); \ No newline at end of file +-- drop function if exists edit_comment(regclass, uuid, text); \ No newline at end of file diff --git a/src/main/resources/sql/functions/comment/find_comment_by_id.sql b/src/main/resources/sql/functions/comment/find_comment_by_id.sql index 7bc481b..c1d48a2 100644 --- a/src/main/resources/sql/functions/comment/find_comment_by_id.sql +++ b/src/main/resources/sql/functions/comment/find_comment_by_id.sql @@ -9,7 +9,9 @@ begin from ( select com.*, - json_build_object('id', com.target_id) as target, +-- TODO use generic object, not article +-- json_build_object('id', com.target_id) as target, + find_article_by_id(com.target_id) as target, find_citizen_by_id(com.created_by_id) as created_by from "comment" as com where id = _id diff --git a/src/test/resources/feature/comment.feature b/src/test/resources/feature/comment.feature new file mode 100644 index 0000000..3f00adb --- /dev/null +++ b/src/test/resources/feature/comment.feature @@ -0,0 +1,35 @@ +Feature: comment Article and Constitution + + # Article + Scenario: The route for comment article must response a 201 and return object + Given I am authenticated as John Doe with id "64b7b379-2298-43ec-b428-ba134930cabd" + And I have article with id "9226c1a3-8091-c3fa-7d0d-c2e98c9bee7b" + When I send a POST request to "/articles/9226c1a3-8091-c3fa-7d0d-c2e98c9bee7b/comments" with body: + """ + Hello mister + """ + Then the response status code should be 201 + + Scenario: The route for get comments of articles must response a 200 and return objects + Given I have citizen John Doe with id "64b7b379-2298-43ec-b428-ba134930cabd" + And I have article with id "9226c1a3-8091-c3fa-7d0d-c2e98c9bee7b" + When I send a GET request to "/citizens/64b7b379-2298-43ec-b428-ba134930cabd/comments/articles" + Then the response status code should be 200 + And the response should contain object: + | current_page | 1 | + | limit | 50 | + + Scenario: The route for edit comment must response a 200 and return object + Given I am authenticated as username 3 with id "92877af7-0a45-fd6a-2ed7-fe81e1236b78" + When I send a PUT request to "/comments/2f01c257-cf20-3466-fb10-a3b8eff12a97" with body: + """ + Hello boy + """ + Then the response status code should be 200 + # TODO check if data is realy edited + And the JSON should contain: + | content | Hello boy | + + + # Constitution + # TODO