Valider les resource entrente #91

Merged
flecomte merged 46 commits from 21-valid-input into master 2021-04-16 03:27:11 +02:00
8 changed files with 113 additions and 29 deletions
Showing only changes of commit 61a7091736 - Show all commits

View File

@@ -1,6 +1,10 @@
package fr.dcproject.application package fr.dcproject.application
import fr.dcproject.application.http.BadRequestException
import fr.dcproject.application.http.HttpErrorBadRequest
import fr.dcproject.application.http.HttpErrorBadRequest.InvalidParam
import io.ktor.features.DataConversion import io.ktor.features.DataConversion
import io.ktor.http.HttpStatusCode
import io.ktor.util.KtorExperimentalAPI import io.ktor.util.KtorExperimentalAPI
import org.koin.core.context.GlobalContext import org.koin.core.context.GlobalContext
import org.koin.core.parameter.ParametersDefinition import org.koin.core.parameter.ParametersDefinition
@@ -8,6 +12,7 @@ import org.koin.core.qualifier.Qualifier
import java.util.UUID import java.util.UUID
private typealias ConverterDeclaration = DataConversion.Configuration.() -> Unit private typealias ConverterDeclaration = DataConversion.Configuration.() -> Unit
private inline fun <reified T> DataConversion.Configuration.get( private inline fun <reified T> DataConversion.Configuration.get(
qualifier: Qualifier? = null, qualifier: Qualifier? = null,
noinline parameters: ParametersDefinition? = null noinline parameters: ParametersDefinition? = null
@@ -17,7 +22,21 @@ private inline fun <reified T> DataConversion.Configuration.get(
val converters: ConverterDeclaration = { val converters: ConverterDeclaration = {
convert<UUID> { convert<UUID> {
decode { values, _ -> decode { values, _ ->
try {
values.singleOrNull()?.let { UUID.fromString(it) } values.singleOrNull()?.let { UUID.fromString(it) }
} catch (e: Throwable) {
throw BadRequestException(
HttpErrorBadRequest(
HttpStatusCode.BadRequest,
invalidParams = listOf(
InvalidParam(
"ID",
"ID must be UUID"
)
)
)
)
}
} }
encode { value -> encode { value ->

View File

@@ -6,6 +6,7 @@ import fr.dcproject.component.auth.ForbiddenException
import fr.dcproject.component.auth.user import fr.dcproject.component.auth.user
import io.ktor.application.call import io.ktor.application.call
import io.ktor.features.NotFoundException import io.ktor.features.NotFoundException
import io.ktor.features.ParameterConversionException
import io.ktor.features.StatusPages import io.ktor.features.StatusPages
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.response.respond import io.ktor.response.respond
@@ -19,10 +20,6 @@ class HttpError(
val detail: String? = null, val detail: String? = null,
) { ) {
val statusCode: Int = statusCode.value val statusCode: Int = statusCode.value
data class InvalidParam(
val name: String,
val reason: String
)
} }
fun statusPagesInstallation(): StatusPages.Configuration.() -> Unit = { fun statusPagesInstallation(): StatusPages.Configuration.() -> Unit = {
@@ -80,4 +77,12 @@ fun statusPagesInstallation(): StatusPages.Configuration.() -> Unit = {
exception<BadRequestException> { e -> exception<BadRequestException> { e ->
call.respond(HttpStatusCode.BadRequest, e.httpError) call.respond(HttpStatusCode.BadRequest, e.httpError)
} }
exception<ParameterConversionException> { e ->
val parent = e.cause
if (parent is BadRequestException) {
call.respond(HttpStatusCode.BadRequest, parent.httpError)
} else {
throw e
}
}
} }

View File

@@ -1,42 +1,69 @@
package fr.dcproject.component.article.routes package fr.dcproject.component.article.routes
import fr.dcproject.application.http.badRequestIfNotValid
import fr.dcproject.common.response.toOutput import fr.dcproject.common.response.toOutput
import fr.dcproject.common.security.assert import fr.dcproject.common.security.assert
import fr.dcproject.common.utils.toUUID
import fr.dcproject.common.validation.isUuid
import fr.dcproject.component.article.ArticleAccessControl import fr.dcproject.component.article.ArticleAccessControl
import fr.dcproject.component.article.database.ArticleForListing import fr.dcproject.component.article.database.ArticleForListing
import fr.dcproject.component.article.database.ArticleRef
import fr.dcproject.component.article.database.ArticleRepository import fr.dcproject.component.article.database.ArticleRepository
import fr.dcproject.component.auth.citizenOrNull import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.routes.PaginatedRequest
import fr.dcproject.routes.PaginatedRequestI
import fr.postgresjson.repository.RepositoryI import fr.postgresjson.repository.RepositoryI
import io.konform.validation.Validation
import io.konform.validation.jsonschema.enum
import io.konform.validation.jsonschema.maximum
import io.konform.validation.jsonschema.minimum
import io.ktor.application.call import io.ktor.application.call
import io.ktor.locations.KtorExperimentalLocationsAPI import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.locations.Location import io.ktor.locations.Location
import io.ktor.locations.get import io.ktor.locations.get
import io.ktor.response.respond import io.ktor.response.respond
import io.ktor.routing.Route import io.ktor.routing.Route
import java.util.UUID
@KtorExperimentalLocationsAPI @KtorExperimentalLocationsAPI
object FindArticleVersions { object FindArticleVersions {
@Location("/articles/{article}/versions") @Location("/articles/{article}/versions")
class ArticleVersionsRequest( class ArticleVersionsRequest(
article: UUID, val article: String,
page: Int = 1, page: Int = 1,
limit: Int = 50, limit: Int = 50,
val sort: String? = null, val sort: String? = null,
val direction: RepositoryI.Direction? = null, val direction: RepositoryI.Direction? = null,
val search: String? = null val search: String? = null
) { ) : PaginatedRequestI by PaginatedRequest(page, limit) {
val page: Int = if (page < 1) 1 else page fun validate() = Validation<ArticleVersionsRequest> {
val limit: Int = if (limit > 50) 50 else if (limit < 1) 1 else limit ArticleVersionsRequest::page {
val article = ArticleRef(article) minimum(1)
maximum(100)
}
ArticleVersionsRequest::limit {
minimum(1)
maximum(50)
}
ArticleVersionsRequest::sort ifPresent {
enum(
"title",
"createdAt",
"vote",
"popularity",
)
}
ArticleVersionsRequest::article {
isUuid()
}
}.validate(this)
} }
private fun ArticleRepository.findVersions(request: ArticleVersionsRequest) = private fun ArticleRepository.findVersions(request: ArticleVersionsRequest) =
findVersionsById(request.page, request.limit, request.article.id) findVersionsById(request.page, request.limit, request.article.toUUID())
fun Route.findArticleVersions(repo: ArticleRepository, ac: ArticleAccessControl) { fun Route.findArticleVersions(repo: ArticleRepository, ac: ArticleAccessControl) {
get<ArticleVersionsRequest> { get<ArticleVersionsRequest> {
it.validate().badRequestIfNotValid()
repo.findVersions(it) repo.findVersions(it)
.apply { ac.assert { canView(result, citizenOrNull) } } .apply { ac.assert { canView(result, citizenOrNull) } }
.run { .run {

View File

@@ -211,6 +211,12 @@ paths:
format: uuid format: uuid
name: name:
type: string type: string
400:
description: BadReqest
content:
application/json:
schema:
$ref: '#/components/schemas/400'
/login: /login:
post: post:

View File

@@ -18,7 +18,7 @@ import integration.steps.then.`And the response should contain`
import integration.steps.then.`And the response should not be null` import integration.steps.then.`And the response should not be null`
import integration.steps.then.`And the response should not contain` import integration.steps.then.`And the response should not contain`
import integration.steps.then.`Then the response should be` import integration.steps.then.`Then the response should be`
import integration.steps.then.`whish contains` import integration.steps.then.`which contains`
import integration.steps.then.and import integration.steps.then.and
import io.ktor.http.HttpStatusCode.Companion.BadRequest import io.ktor.http.HttpStatusCode.Companion.BadRequest
import io.ktor.http.HttpStatusCode.Companion.Forbidden import io.ktor.http.HttpStatusCode.Companion.Forbidden
@@ -46,6 +46,7 @@ class `Article routes` : BaseTest() {
} }
@Test @Test
@Tag("Validation")
fun `I cannot get article list`() { fun `I cannot get article list`() {
withIntegrationApplication { withIntegrationApplication {
`Given I have articles`(3) `Given I have articles`(3)
@@ -64,8 +65,8 @@ class `Article routes` : BaseTest() {
`Given I have article created by workgroup`("2bccd5a7-9082-4b31-88f8-e25d70b22b12") `Given I have article created by workgroup`("2bccd5a7-9082-4b31-88f8-e25d70b22b12")
`When I send a GET request`("/articles?workgroup=2bccd5a7-9082-4b31-88f8-e25d70b22b12") `Then the response should be` OK and { `When I send a GET request`("/articles?workgroup=2bccd5a7-9082-4b31-88f8-e25d70b22b12") `Then the response should be` OK and {
`And the response should not be null`() `And the response should not be null`()
`And have property`("$.total") `whish contains` 1 `And have property`("$.total") `which contains` 1
`And have property`("$.result[0]workgroup.name") `whish contains` "Les papy" `And have property`("$.result[0]workgroup.name") `which contains` "Les papy"
} }
} }
} }
@@ -76,7 +77,7 @@ class `Article routes` : BaseTest() {
`Given I have article`(id = "65cda9f3-8991-4420-8d41-1da9da72c9bb") `Given I have article`(id = "65cda9f3-8991-4420-8d41-1da9da72c9bb")
`When I send a GET request`("/articles/65cda9f3-8991-4420-8d41-1da9da72c9bb") `Then the response should be` OK and { `When I send a GET request`("/articles/65cda9f3-8991-4420-8d41-1da9da72c9bb") `Then the response should be` OK and {
`And the response should not be null`() `And the response should not be null`()
`And have property`("$.id") `whish contains` "65cda9f3-8991-4420-8d41-1da9da72c9bb" `And have property`("$.id") `which contains` "65cda9f3-8991-4420-8d41-1da9da72c9bb"
} }
} }
} }
@@ -85,10 +86,36 @@ class `Article routes` : BaseTest() {
fun `I can get versions of article by the id`() { fun `I can get versions of article by the id`() {
withIntegrationApplication { withIntegrationApplication {
`Given I have article`(id = "13e6091c-8fed-4600-b079-a97a6b7a9800") `Given I have article`(id = "13e6091c-8fed-4600-b079-a97a6b7a9800")
`When I send a GET request`("/articles/13e6091c-8fed-4600-b079-a97a6b7a9800/versions") `Then the response should be` OK and { `When I send a GET request`("/articles/13e6091c-8fed-4600-b079-a97a6b7a9800/versions?page=1&limit=10&sort=title") `Then the response should be` OK and {
`And the response should not be null`() `And the response should not be null`()
`And have property`("$.total") `whish contains` 1 `And have property`("$.total") `which contains` 1
`And have property`("$.result[0].id") `whish contains` "13e6091c-8fed-4600-b079-a97a6b7a9800" `And have property`("$.result[0].id") `which contains` "13e6091c-8fed-4600-b079-a97a6b7a9800"
}
}
}
@Test
@Tag("Validation")
fun `I cannot get versions of article by the id with wrong id`() {
withIntegrationApplication {
`Given I have article`(id = "13e6091c-8fed-4600-b079-a97a6b7a9800")
`When I send a GET request`("/articles/abcd/versions") `Then the response should be` BadRequest and {
`And the response should not be null`()
`And the response should contain`("$.invalidParams[0].name", ".article")
`And the response should contain`("$.invalidParams[0].reason", "must be UUID")
}
}
}
@Test
@Tag("Validation")
fun `I cannot get versions of article by the id with wrong request`() {
withIntegrationApplication {
`Given I have article`(id = "13e6091c-8fed-4600-b079-a97a6b7a9800")
`When I send a GET request`("/articles/13e6091c-8fed-4600-b079-a97a6b7a9800/versions?page=1&limit=10&sort=wrong") `Then the response should be` BadRequest and {
`And the response should not be null`()
`And the response should contain`("$.invalidParams[0].name", ".sort")
`And the response should contain pattern`("$.invalidParams[0].reason", "must be one of: ('[^']+'(, )?)+")
} }
} }
} }
@@ -115,7 +142,7 @@ class `Article routes` : BaseTest() {
) )
} `Then the response should be` OK and { } `Then the response should be` OK and {
`And the response should not be null`() `And the response should not be null`()
`And have property`("$.versionId") `whish contains` "09c418b6-63ba-448b-b38b-502b41cd500e" `And have property`("$.versionId") `which contains` "09c418b6-63ba-448b-b38b-502b41cd500e"
} }
} }
} }

View File

@@ -9,7 +9,7 @@ import integration.steps.given.`authenticated as`
import integration.steps.then.`And have property` import integration.steps.then.`And have property`
import integration.steps.then.`And the response should not be null` import integration.steps.then.`And the response should not be null`
import integration.steps.then.`Then the response should be` import integration.steps.then.`Then the response should be`
import integration.steps.then.`whish contains` import integration.steps.then.`which contains`
import integration.steps.then.and import integration.steps.then.and
import io.ktor.http.HttpStatusCode.Companion.BadRequest import io.ktor.http.HttpStatusCode.Companion.BadRequest
import io.ktor.http.HttpStatusCode.Companion.Created import io.ktor.http.HttpStatusCode.Companion.Created
@@ -42,7 +42,7 @@ class `Citizen routes` : BaseTest() {
`authenticated as`("Linus", "Pauling") `authenticated as`("Linus", "Pauling")
} `Then the response should be` OK and { } `Then the response should be` OK and {
`And the response should not be null`() `And the response should not be null`()
`And have property`("$.id") `whish contains` "47a05c0f-7329-46c3-a7d0-325db37e9114" `And have property`("$.id") `which contains` "47a05c0f-7329-46c3-a7d0-325db37e9114"
} }
} }
} }
@@ -55,7 +55,7 @@ class `Citizen routes` : BaseTest() {
`authenticated as`("Henri", "Becquerel") `authenticated as`("Henri", "Becquerel")
} `Then the response should be` OK and { } `Then the response should be` OK and {
`And the response should not be null`() `And the response should not be null`()
`And have property`("$.id") `whish contains` "47356809-c8ef-4649-8b99-1c5cb9886d38" `And have property`("$.id") `which contains` "47356809-c8ef-4649-8b99-1c5cb9886d38"
} }
} }
} }

View File

@@ -11,7 +11,7 @@ import integration.steps.given.`authenticated as`
import integration.steps.then.`And have property` import integration.steps.then.`And have property`
import integration.steps.then.`And the response should not be null` import integration.steps.then.`And the response should not be null`
import integration.steps.then.`Then the response should be` import integration.steps.then.`Then the response should be`
import integration.steps.then.`whish contains` import integration.steps.then.`which contains`
import integration.steps.then.and import integration.steps.then.and
import io.ktor.http.HttpStatusCode.Companion.BadRequest import io.ktor.http.HttpStatusCode.Companion.BadRequest
import io.ktor.http.HttpStatusCode.Companion.Created import io.ktor.http.HttpStatusCode.Companion.Created
@@ -41,7 +41,7 @@ class `Constitution routes` : BaseTest() {
`Given I have constitution`("0321c8d1-4ce3-4763-b5f4-a92611d280b4") `Given I have constitution`("0321c8d1-4ce3-4763-b5f4-a92611d280b4")
`When I send a GET request`("/constitutions/0321c8d1-4ce3-4763-b5f4-a92611d280b4") `Then the response should be` OK and { `When I send a GET request`("/constitutions/0321c8d1-4ce3-4763-b5f4-a92611d280b4") `Then the response should be` OK and {
`And the response should not be null`() `And the response should not be null`()
`And have property`("$.id") `whish contains` "0321c8d1-4ce3-4763-b5f4-a92611d280b4" `And have property`("$.id") `which contains` "0321c8d1-4ce3-4763-b5f4-a92611d280b4"
} }
} }
} }
@@ -82,8 +82,8 @@ class `Constitution routes` : BaseTest() {
) )
} `Then the response should be` Created and { } `Then the response should be` Created and {
`And the response should not be null`() `And the response should not be null`()
`And have property`("$.versionId") `whish contains` "15814bb6-8d90-4c6a-a456-c3939a8ec75e" `And have property`("$.versionId") `which contains` "15814bb6-8d90-4c6a-a456-c3939a8ec75e"
`And have property`("$.title") `whish contains` "Hello world!" `And have property`("$.title") `which contains` "Hello world!"
} }
} }
} }

View File

@@ -46,7 +46,7 @@ infix fun TestApplicationResponse.`And have property`(path: String): Pair<JsonPa
} ?: throw AssertionError("\"${path}\" element not found on json response") } ?: throw AssertionError("\"${path}\" element not found on json response")
} }
infix fun Pair<JsonPath, Any>.`whish contains`(expected: Any): Pair<JsonPath, Any> = this.apply { infix fun Pair<JsonPath, Any>.`which contains`(expected: Any): Pair<JsonPath, Any> = this.apply {
second `should be equal to` expected second `should be equal to` expected
} }