feature #7: Add routes for comment article

This commit is contained in:
2019-08-27 22:24:38 +02:00
parent ff1e34c616
commit 1fb0e39038
12 changed files with 364 additions and 10 deletions

View File

@@ -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<CommentEntityGeneric> {
decode { values, _ ->
val id = values.singleOrNull()?.let { UUID.fromString(it) }
?: throw InternalError("Cannot convert $values to UUID")
get<CommentGenericRepository>().findById(id) ?: throw InternalError("Comment $values not found")
}
}
convert<Citizen> {
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())
}
}

View File

@@ -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) }
}

View File

@@ -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 <T: UuidEntity> (
id: UUID = UUID.randomUUID(),
createdBy: Citizen,
override var target: T,
var content: String,
var responses: List<Comment<T>>? = null,
var parent: Comment<T>? = null,
var parentsIds: List<UUID>? = null
): Extra<T>(id, createdBy),
EntityUpdatedAt by EntityUpdatedAtImp()

View File

@@ -10,7 +10,7 @@ interface ExtraI <T: EntityI<UUID>>:
var target: T
}
abstract class Extra<T: EntityI<UUID>>(
abstract class Extra<T: UuidEntity>(
id: UUID? = UUID.randomUUID(),
createdBy: Citizen
):

View File

@@ -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 <T: UuidEntity>(override var requester: Requester): RepositoryI<CommentEntity<T>> {
override val entityName = CommentEntity::class as KClass<CommentEntity<T>>
open fun findByCitizen(
citizen: CitizenEntity,
page: Int = 1,
limit: Int = 50
): Paginated<CommentEntity<T>> =
findByCitizen(citizen.id ?: error("The citizen must have an id"), page, limit)
open fun findByCitizen(
citizenId: UUID,
page: Int = 1,
limit: Int = 50
): Paginated<CommentEntity<T>> {
return requester.run {
getFunction("find_comments_by_citizen")
.select(page, limit,
"created_by_id" to citizenId
)
}
}
open fun findByParent(
parent: CommentEntity<T>,
page: Int = 1,
limit: Int = 50
): Paginated<CommentEntity<T>> {
return findByParent(parent.id ?: error("comment must have an ID"), page, limit)
}
open fun findByParent(
parentId: UUID,
page: Int = 1,
limit: Int = 50
): Paginated<CommentEntity<T>> {
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<CommentEntity<T>> {
return findByTarget(target.id ?: error("comment must have an ID"), page, limit)
}
open fun findByTarget(
targetId: UUID,
page: Int = 1,
limit: Int = 50
): Paginated<CommentEntity<T>> {
return requester.run {
getFunction("find_comments_by_target")
.select(page, limit,
"target_id" to targetId
)
}
}
fun comment(comment: CommentEntity<T>) {
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<T>) {
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<UuidEntity>(requester) {
fun findById(id: UUID): CommentEntity<ArticleEntity>? {
return requester
.getFunction("find_comment_by_id")
.selectOne(mapOf("id" to id))
}
}
class CommentArticle (requester: Requester): Comment<ArticleEntity>(requester) {
override fun findByCitizen(
citizenId: UUID,
page: Int,
limit: Int
): Paginated<CommentEntity<ArticleEntity>> {
return requester.run {
getFunction("find_comments_article_by_citizen")
.select(page, limit,
"created_by_id" to citizenId
)
}
}
}
class CommentConstitution (requester: Requester): Comment<ConstitutionEntity>(requester) {
override fun findByCitizen(
citizenId: UUID,
page: Int,
limit: Int
): Paginated<CommentEntity<ConstitutionEntity>> {
return requester.run {
getFunction("find_comments_constitution_by_citizen")
.select(page, limit,
"created_by_id" to citizenId
)
}
}
}

View File

@@ -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<UuidEntity>
@KtorExperimentalLocationsAPI
object CommentPaths {
// TODO: change UUID by entity converter
@Location("/comments/{comment}") class CommentRequest(val comment: UUID)
}
@KtorExperimentalLocationsAPI
fun Route.comment(repo: CommentRepository) {
get<CommentPaths.CommentRequest> {
val comment = repo.findById(it.comment)!!
assertCan(VIEW, comment)
call.respond(HttpStatusCode.OK, comment)
}
put<CommentPaths.CommentRequest> {
val comment = repo.findById(it.comment)!!
assertCan(UPDATE,comment)
comment.content = call.receiveText()
repo.edit(comment as fr.dcproject.entity.Comment<UuidEntity>)
call.respond(HttpStatusCode.OK, comment)
}
}

View File

@@ -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<CommentArticlePaths.ArticleCommentRequest> {
assertCan(VIEW, it.article)
val comment = repo.findByTarget(it.article)
call.respond(HttpStatusCode.OK, comment)
}
post<CommentArticlePaths.ArticleCommentRequest> {
assertCan(CREATE, it.article)
val content = call.receive<String>()
val comment = CommentEntity(
target = it.article,
createdBy = citizen,
content = content
)
repo.comment(comment)
call.respond(HttpStatusCode.Created, comment)
}
get<CommentArticlePaths.CitizenCommentArticleRequest> {
val comments = repo.findByCitizen(it.citizen)
call.respond(comments)
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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);
-- drop function if exists edit_comment(regclass, uuid, text);

View File

@@ -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

View File

@@ -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