From 8223dd21bbd6141280a18a04e9efdc2bd2f87810 Mon Sep 17 00:00:00 2001 From: Fabrice Lecomte Date: Sat, 10 Apr 2021 01:16:09 +0200 Subject: [PATCH] Add validation on route CreateComments & EditComment rename POST /comments/{comment}/children method edit and create comment of repository return edited/created comment --- .../comment/generic/database/Comment.kt | 6 +- .../generic/database/CommentRepository.kt | 42 ++++----- .../comment/generic/routes/CreateComment.kt | 63 +++++++++++++ .../generic/routes/CreateCommentChildren.kt | 47 ---------- .../comment/generic/routes/EditComment.kt | 44 ++++++--- .../comment/generic/routes/install.kt | 2 +- src/main/resources/openapi.yaml | 42 ++++++++- .../sql/functions/comment/comment.sql | 5 +- .../sql/functions/comment/edit_comment.sql | 4 +- src/test/kotlin/integration/Comment routes.kt | 94 +++++++++++++++++-- .../kotlin/integration/steps/given/Comment.kt | 65 ++++++++++--- 11 files changed, 307 insertions(+), 107 deletions(-) create mode 100644 src/main/kotlin/fr/dcproject/component/comment/generic/routes/CreateComment.kt delete mode 100644 src/main/kotlin/fr/dcproject/component/comment/generic/routes/CreateCommentChildren.kt diff --git a/src/main/kotlin/fr/dcproject/component/comment/generic/database/Comment.kt b/src/main/kotlin/fr/dcproject/component/comment/generic/database/Comment.kt index e18cdf6..fdb95f0 100644 --- a/src/main/kotlin/fr/dcproject/component/comment/generic/database/Comment.kt +++ b/src/main/kotlin/fr/dcproject/component/comment/generic/database/Comment.kt @@ -63,12 +63,14 @@ open class CommentForUpdate( constructor( createdBy: C, parent: CommentParent, - content: String + content: String, + id: UUID? = null, ) : this( createdBy = createdBy, parent = parent, target = parent.target, - content = content + content = content, + id = id ?: UUID.randomUUID(), ) } diff --git a/src/main/kotlin/fr/dcproject/component/comment/generic/database/CommentRepository.kt b/src/main/kotlin/fr/dcproject/component/comment/generic/database/CommentRepository.kt index 6443819..b5fdf8a 100644 --- a/src/main/kotlin/fr/dcproject/component/comment/generic/database/CommentRepository.kt +++ b/src/main/kotlin/fr/dcproject/component/comment/generic/database/CommentRepository.kt @@ -58,35 +58,29 @@ abstract class CommentRepositoryAbs(override var requester: Request page: Int = 1, limit: Int = 50, sort: String = "createdAt" - ): Paginated> { - return requester.run { - getFunction("find_comments_by_target") - .select>( - page, - limit, - "target_id" to targetId, - "sort" to sort - ) - as Paginated> - } - } + ): Paginated> = requester + .getFunction("find_comments_by_target") + .select>( + page, + limit, + "target_id" to targetId, + "sort" to sort + ) as Paginated> - fun comment(comment: CommentForUpdate) { - requester - .getFunction("comment") - .sendQuery( - "reference" to comment.target.reference, - "resource" to comment - ) - } + fun comment(comment: CommentForUpdate): CommentForView = requester + .getFunction("comment") + .selectOne( + "reference" to comment.target.reference, + "resource" to comment + )!! - fun edit(comment: CommentForUpdate) { - requester + fun edit(comment: CommentForUpdate): CommentForView { + return requester .getFunction("edit_comment") - .sendQuery( + .selectOne( "id" to comment.id, "content" to comment.content - ) + )!! } } diff --git a/src/main/kotlin/fr/dcproject/component/comment/generic/routes/CreateComment.kt b/src/main/kotlin/fr/dcproject/component/comment/generic/routes/CreateComment.kt new file mode 100644 index 0000000..6158794 --- /dev/null +++ b/src/main/kotlin/fr/dcproject/component/comment/generic/routes/CreateComment.kt @@ -0,0 +1,63 @@ +package fr.dcproject.component.comment.generic.routes + +import fr.dcproject.application.http.badRequestIfNotValid +import fr.dcproject.common.security.assert +import fr.dcproject.common.utils.receiveOrBadRequest +import fr.dcproject.component.auth.citizen +import fr.dcproject.component.auth.citizenOrNull +import fr.dcproject.component.auth.mustBeAuth +import fr.dcproject.component.comment.generic.CommentAccessControl +import fr.dcproject.component.comment.generic.database.CommentForUpdate +import fr.dcproject.component.comment.generic.database.CommentRef +import fr.dcproject.component.comment.generic.database.CommentRepository +import fr.dcproject.component.comment.toOutput +import io.konform.validation.Validation +import io.konform.validation.jsonschema.maxLength +import io.konform.validation.jsonschema.minLength +import io.ktor.application.call +import io.ktor.features.NotFoundException +import io.ktor.http.HttpStatusCode +import io.ktor.locations.KtorExperimentalLocationsAPI +import io.ktor.locations.Location +import io.ktor.locations.post +import io.ktor.response.respond +import io.ktor.routing.Route +import java.util.UUID + +@KtorExperimentalLocationsAPI +object CreateComment { + @Location("/comments/{comment}") + class CreateCommentRequest(comment: UUID) { + val comment = CommentRef(comment) + class Input(val content: String) { + fun validate() = Validation { + Input::content { + minLength(20) + maxLength(6000) + } + }.validate(this) + } + } + + fun Route.createCommentChildren(repo: CommentRepository, ac: CommentAccessControl) { + post { + mustBeAuth() + + call.receiveOrBadRequest() + .apply { validate().badRequestIfNotValid() } + .run { + val parent = repo.findById(it.comment.id) ?: throw NotFoundException("Comment not found") + CommentForUpdate( + content = content, + createdBy = citizen, + target = parent.target, + parent = parent, + ) + }.let { newComment -> + ac.assert { canCreate(newComment, citizenOrNull) } + repo.comment(newComment) + call.respond(HttpStatusCode.Created, newComment.toOutput()) + } + } + } +} diff --git a/src/main/kotlin/fr/dcproject/component/comment/generic/routes/CreateCommentChildren.kt b/src/main/kotlin/fr/dcproject/component/comment/generic/routes/CreateCommentChildren.kt deleted file mode 100644 index b95d2cd..0000000 --- a/src/main/kotlin/fr/dcproject/component/comment/generic/routes/CreateCommentChildren.kt +++ /dev/null @@ -1,47 +0,0 @@ -package fr.dcproject.component.comment.generic.routes - -import fr.dcproject.common.security.assert -import fr.dcproject.common.utils.receiveOrBadRequest -import fr.dcproject.component.auth.citizen -import fr.dcproject.component.auth.citizenOrNull -import fr.dcproject.component.auth.mustBeAuth -import fr.dcproject.component.comment.generic.CommentAccessControl -import fr.dcproject.component.comment.generic.database.CommentForUpdate -import fr.dcproject.component.comment.generic.database.CommentRef -import fr.dcproject.component.comment.generic.database.CommentRepository -import fr.dcproject.component.comment.toOutput -import io.ktor.application.call -import io.ktor.features.NotFoundException -import io.ktor.http.HttpStatusCode -import io.ktor.locations.KtorExperimentalLocationsAPI -import io.ktor.locations.Location -import io.ktor.locations.post -import io.ktor.response.respond -import io.ktor.routing.Route -import java.util.UUID - -@KtorExperimentalLocationsAPI -object CreateCommentChildren { - @Location("/comments/{comment}/children") - class CreateCommentChildrenRequest(comment: UUID) { - val comment = CommentRef(comment) - class Input(val content: String) - } - - fun Route.createCommentChildren(repo: CommentRepository, ac: CommentAccessControl) { - post { - mustBeAuth() - val parent = repo.findById(it.comment.id) ?: throw NotFoundException("Comment not found") - val newComment = CommentForUpdate( - content = call.receiveOrBadRequest().content, - createdBy = citizen, - parent = parent - ) - - ac.assert { canCreate(newComment, citizenOrNull) } - repo.comment(newComment) - - call.respond(HttpStatusCode.Created, newComment.toOutput()) - } - } -} diff --git a/src/main/kotlin/fr/dcproject/component/comment/generic/routes/EditComment.kt b/src/main/kotlin/fr/dcproject/component/comment/generic/routes/EditComment.kt index 7aa20c0..b4e1522 100644 --- a/src/main/kotlin/fr/dcproject/component/comment/generic/routes/EditComment.kt +++ b/src/main/kotlin/fr/dcproject/component/comment/generic/routes/EditComment.kt @@ -1,14 +1,18 @@ package fr.dcproject.component.comment.generic.routes -import fr.dcproject.common.response.toOutput +import fr.dcproject.application.http.badRequestIfNotValid import fr.dcproject.common.security.assert import fr.dcproject.common.utils.receiveOrBadRequest import fr.dcproject.component.auth.citizenOrNull import fr.dcproject.component.auth.mustBeAuth import fr.dcproject.component.comment.generic.CommentAccessControl +import fr.dcproject.component.comment.generic.database.CommentForUpdate import fr.dcproject.component.comment.generic.database.CommentRef import fr.dcproject.component.comment.generic.database.CommentRepository import fr.dcproject.component.comment.toOutput +import io.konform.validation.Validation +import io.konform.validation.jsonschema.maxLength +import io.konform.validation.jsonschema.minLength import io.ktor.application.call import io.ktor.features.NotFoundException import io.ktor.http.HttpStatusCode @@ -24,22 +28,40 @@ object EditComment { @Location("/comments/{comment}") class EditCommentRequest(comment: UUID) { val comment = CommentRef(comment) - class Input(val content: String) + class Input(val content: String) { + fun validate() = Validation { + Input::content { + minLength(20) + maxLength(6000) + } + }.validate(this) + } } fun Route.editComment(repo: CommentRepository, ac: CommentAccessControl) { put { mustBeAuth() - val comment = repo.findById(it.comment.id) ?: throw NotFoundException("Comment not found") - ac.assert { canUpdate(comment, citizenOrNull) } + val commentOld = repo.findById(it.comment.id) ?: throw NotFoundException("Comment not found") + ac.assert { canUpdate(commentOld, citizenOrNull) } - comment.content = call.receiveOrBadRequest().content - repo.edit(comment) - - call.respond( - HttpStatusCode.OK, - comment.toOutput() - ) + call.receiveOrBadRequest() + .apply { validate().badRequestIfNotValid() } + .run { + CommentForUpdate( + id = commentOld.id, + createdBy = commentOld.createdBy, + target = commentOld.target, + parent = commentOld.parent, + content = content, + ) + } + .let { repo.edit(it) } + .let { + call.respond( + HttpStatusCode.OK, + it.toOutput() + ) + } } } } diff --git a/src/main/kotlin/fr/dcproject/component/comment/generic/routes/install.kt b/src/main/kotlin/fr/dcproject/component/comment/generic/routes/install.kt index 8ea983c..3ca3711 100644 --- a/src/main/kotlin/fr/dcproject/component/comment/generic/routes/install.kt +++ b/src/main/kotlin/fr/dcproject/component/comment/generic/routes/install.kt @@ -1,6 +1,6 @@ package fr.dcproject.component.comment.generic.routes -import fr.dcproject.component.comment.generic.routes.CreateCommentChildren.createCommentChildren +import fr.dcproject.component.comment.generic.routes.CreateComment.createCommentChildren import fr.dcproject.component.comment.generic.routes.EditComment.editComment import fr.dcproject.component.comment.generic.routes.GetCommentChildren.getChildrenComments import fr.dcproject.component.comment.generic.routes.GetOneComment.getOneComment diff --git a/src/main/resources/openapi.yaml b/src/main/resources/openapi.yaml index c0a4348..326f39b 100644 --- a/src/main/resources/openapi.yaml +++ b/src/main/resources/openapi.yaml @@ -598,6 +598,40 @@ paths: application/json: schema: $ref: '#/components/schemas/CommentResponse' + post: + security: + - JWTAuth: [] + summary: create comment + tags: + - comment + requestBody: + content: + application/json: + schema: + required: + - content + properties: + content: + type: string + example: + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + responses: + 201: + description: Return updated comment + content: + application/json: + schema: + $ref: '#/components/schemas/CommentResponse' + 400: + description: BadReqest + content: + application/json: + schema: + $ref: '#/components/schemas/400' + 401: + $ref: '#/components/responses/401' + 404: + description: No comment found put: security: - JWTAuth: [] @@ -614,7 +648,7 @@ paths: content: type: string example: - Lorem ipsum... + Lorem ipsum dolor sit amet, consectetur adipiscing elit. responses: 200: description: Return updated comment @@ -622,6 +656,12 @@ paths: application/json: schema: $ref: '#/components/schemas/CommentResponse' + 400: + description: BadReqest + content: + application/json: + schema: + $ref: '#/components/schemas/400' 401: $ref: '#/components/responses/401' /comments/{comment}/children: diff --git a/src/main/resources/sql/functions/comment/comment.sql b/src/main/resources/sql/functions/comment/comment.sql index 803515b..b4228e2 100644 --- a/src/main/resources/sql/functions/comment/comment.sql +++ b/src/main/resources/sql/functions/comment/comment.sql @@ -1,4 +1,4 @@ -create or replace function comment(reference regclass, resource json, out _id uuid) +create or replace function comment(reference regclass, inout resource json) language plpgsql as $$ declare @@ -17,7 +17,8 @@ begin else raise exception 'comment with target as "%", is not implemented', reference::text; end if; - _id = _new_id; + + select find_comment_by_id(_new_id) into resource; end; $$; diff --git a/src/main/resources/sql/functions/comment/edit_comment.sql b/src/main/resources/sql/functions/comment/edit_comment.sql index f68039e..8485f89 100644 --- a/src/main/resources/sql/functions/comment/edit_comment.sql +++ b/src/main/resources/sql/functions/comment/edit_comment.sql @@ -1,9 +1,11 @@ -create or replace function edit_comment(_id uuid, _content text) returns void +create or replace function edit_comment(_id uuid, _content text, out resource json) language plpgsql as $$ begin update comment c set "content" = _content where c.id = _id; + + select find_comment_by_id(_id) into resource; end; $$; diff --git a/src/test/kotlin/integration/Comment routes.kt b/src/test/kotlin/integration/Comment routes.kt index 15ff4cf..a57acde 100644 --- a/src/test/kotlin/integration/Comment routes.kt +++ b/src/test/kotlin/integration/Comment routes.kt @@ -2,16 +2,20 @@ package integration import fr.dcproject.component.citizen.database.CitizenI import integration.steps.`when`.`When I send a GET request` +import integration.steps.`when`.`When I send a POST request` import integration.steps.`when`.`When I send a PUT request` import integration.steps.`when`.`with body` import integration.steps.given.`Given I have article` import integration.steps.given.`Given I have citizen` import integration.steps.given.`Given I have comment on article` +import integration.steps.given.`Given I have comment on comment` import integration.steps.given.`authenticated as` import integration.steps.then.`And the response should contain` import integration.steps.then.`And the response should not be null` import integration.steps.then.`Then the response should be` import integration.steps.then.and +import io.ktor.http.HttpStatusCode.Companion.BadRequest +import io.ktor.http.HttpStatusCode.Companion.Created import io.ktor.http.HttpStatusCode.Companion.OK import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Tags @@ -35,27 +39,105 @@ class `Comment routes` : BaseTest() { } } + @Test + fun `I can create comment`() { + withIntegrationApplication { + `Given I have citizen`("Hubert", "Reeves") + `Given I have comment on comment`(id = "49933147-fc0f-4e5c-aa8d-f77fa0d88fa6") + `When I send a POST request`("/comments/49933147-fc0f-4e5c-aa8d-f77fa0d88fa6") { + `authenticated as`("Hubert", "Reeves") + `with body`( + """ + { + "content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit." + } + """ + ) + } `Then the response should be` Created and { + `And the response should not be null`() + `And the response should contain`("$.content", "Lorem ipsum dolor sit amet, consectetur adipiscing elit.") + } + } + } + + @Test + @Tag("BadRequest") + fun `I cannot create comment with bad request`() { + withIntegrationApplication { + `Given I have citizen`("Hubert", "Reeves") + `Given I have comment on comment`(id = "49933147-fc0f-4e5c-aa8d-f77fa0d88fa6") + `When I send a POST request`("/comments/49933147-fc0f-4e5c-aa8d-f77fa0d88fa6") { + `authenticated as`("Hubert", "Reeves") + `with body`( + """ + { + "content": "small content" + } + """ + ) + } `Then the response should be` BadRequest and { + `And the response should not be null`() + `And the response should contain`("$.invalidParams[0].name", ".content") + `And the response should contain`("$.invalidParams[0].reason", "must have at least 20 characters") + } + } + } + @Test fun `I can edit comment`() { withIntegrationApplication { `Given I have citizen`("Hubert", "Reeves") `Given I have article`(id = "bb05e4a3-55a1-4088-85e7-8d8c23be29b1") - `Given I have comment on article`(article = "bb05e4a3-55a1-4088-85e7-8d8c23be29b1", createdBy = CitizenI.Name( - "Hubert", - "Reeves" - ), id = "fd30d20f-656c-42c6-8955-f61c04537464") + `Given I have comment on article`( + article = "bb05e4a3-55a1-4088-85e7-8d8c23be29b1", + createdBy = CitizenI.Name( + "Hubert", + "Reeves" + ), + id = "fd30d20f-656c-42c6-8955-f61c04537464" + ) `When I send a PUT request`("/comments/fd30d20f-656c-42c6-8955-f61c04537464") { `authenticated as`("Hubert", "Reeves") `with body`( """ { - "content": "Hello boy" + "content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit." } """ ) } `Then the response should be` OK and { `And the response should not be null`() - `And the response should contain`("$.content", "Hello boy") + `And the response should contain`("$.content", "Lorem ipsum dolor sit amet, consectetur adipiscing elit.") + } + } + } + + @Test + fun `I cannot edit comment with bad request`() { + withIntegrationApplication { + `Given I have citizen`("Hubert", "Reeves") + `Given I have article`(id = "bb05e4a3-55a1-4088-85e7-8d8c23be29b1") + `Given I have comment on article`( + article = "bb05e4a3-55a1-4088-85e7-8d8c23be29b1", + createdBy = CitizenI.Name( + "Hubert", + "Reeves" + ), + id = "fd30d20f-656c-42c6-8955-f61c04537464" + ) + `When I send a PUT request`("/comments/fd30d20f-656c-42c6-8955-f61c04537464") { + `authenticated as`("Hubert", "Reeves") + `with body`( + """ + { + "content": "small content" + } + """ + ) + } `Then the response should be` BadRequest and { + `And the response should not be null`() + `And the response should contain`("$.invalidParams[0].name", ".content") + `And the response should contain`("$.invalidParams[0].reason", "must have at least 20 characters") } } } diff --git a/src/test/kotlin/integration/steps/given/Comment.kt b/src/test/kotlin/integration/steps/given/Comment.kt index 20689b5..6dee80e 100644 --- a/src/test/kotlin/integration/steps/given/Comment.kt +++ b/src/test/kotlin/integration/steps/given/Comment.kt @@ -2,11 +2,16 @@ package integration.steps.given import com.thedeanda.lorem.LoremIpsum import fr.dcproject.common.entity.TargetI +import fr.dcproject.common.entity.TargetRef import fr.dcproject.common.utils.toUUID import fr.dcproject.component.article.database.ArticleRef import fr.dcproject.component.article.database.ArticleRepository +import fr.dcproject.component.citizen.database.CitizenCreator import fr.dcproject.component.citizen.database.CitizenI.Name import fr.dcproject.component.comment.generic.database.CommentForUpdate +import fr.dcproject.component.comment.generic.database.CommentForView +import fr.dcproject.component.comment.generic.database.CommentI +import fr.dcproject.component.comment.generic.database.CommentRef import fr.dcproject.component.comment.generic.database.CommentRepository import fr.dcproject.component.constitution.database.ConstitutionRef import fr.dcproject.component.constitution.database.ConstitutionRepository @@ -32,14 +37,14 @@ fun TestApplicationEngine.`Given I have comments on article`( } } -fun createComment( +fun createComment( id: UUID? = null, - article: ArticleRef? = null, + article: A? = null, createdBy: Name? = null, content: String? = null -) { +): CommentForView { val articleRepository: ArticleRepository by lazy { GlobalContext.get().koin.get() } - createCommentOnTarget( + return createCommentOnTarget( id, article?.id?.let { articleRepository.findById(article.id) } ?: createArticle(article?.id), createdBy, @@ -56,14 +61,14 @@ fun TestApplicationEngine.`Given I have comment on constitution`( createComment(id?.toUUID(), ConstitutionRef(constitution?.toUUID()), createdBy, content) } -fun createComment( +fun createComment( id: UUID? = null, - constitution: ConstitutionRef? = null, + constitution: C? = null, createdBy: Name? = null, content: String? = null -) { +): CommentForView { val constitutionRepository: ConstitutionRepository by lazy { GlobalContext.get().koin.get() } - createCommentOnTarget( + return createCommentOnTarget( id, constitution?.id?.let { constitutionRepository.findById(constitution.id) } ?: createConstitution(constitution?.id), createdBy, @@ -71,12 +76,12 @@ fun createComment( ) } -fun createCommentOnTarget( +fun createCommentOnTarget( id: UUID? = null, - target: TargetI, + target: T, createdBy: Name? = null, content: String? = null -) { +): CommentForView { val commentRepository: CommentRepository by lazy { GlobalContext.get().koin.get() } val creator = createCitizen(createdBy) val comment = CommentForUpdate( @@ -85,5 +90,41 @@ fun createCommentOnTarget( target = target, content = content ?: LoremIpsum().getParagraphs(1, 3) ) - commentRepository.comment(comment) + return commentRepository.comment(comment) +} + +fun TestApplicationEngine.`Given I have comment on comment`( + id: String? = null, + parent: String? = null, + createdBy: Name? = null, + content: String? = null, +): CommentForView { + return createCommentOnComment( + id?.toUUID() ?: UUID.randomUUID(), + parent?.run { CommentRef(toUUID()) }, + createdBy, + content, + ) +} + +fun createCommentOnComment( + id: UUID? = null, + parent: CommentI? = createComment(), + createdBy: Name? = null, + content: String? = null +): CommentForView { + val creator = createCitizen(createdBy) + val commentRepository: CommentRepository by lazy { GlobalContext.get().koin.get() } + val parentComment = if (parent == null) { + createComment() + } else { + commentRepository.findById(parent.id) ?: error("Parent of comment not found") + } + val comment = CommentForUpdate( + id = id ?: UUID.randomUUID(), + createdBy = creator, + content = content ?: LoremIpsum().getParagraphs(1, 3), + parent = parentComment, + ) + return commentRepository.comment(comment) }