Skip to content

Commit a22b055

Browse files
committed
fix validation of lists
List<X> NOT validated by the framework !!!! - use a list wrapper in controller methods, which is unwrapped to a list by jackson - the wrapper is a valid java bean, hence validation will occur - see https://stackoverflow.com/a/64061936
1 parent e3b5630 commit a22b055

10 files changed

Lines changed: 155 additions & 67 deletions

File tree

src/main/kotlin/ch/derlin/bbdata/common/Beans.kt

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,59 @@
11
package ch.derlin.bbdata.common
22

3+
import com.fasterxml.jackson.annotation.JsonCreator
4+
import com.fasterxml.jackson.annotation.JsonValue
5+
import javax.validation.Valid
6+
import javax.validation.constraints.NotNull
37
import javax.validation.constraints.Size
48

59
/**
610
* date: 07.12.19
711
* @author Lucy Linder <lucy.derlin@gmail.com>
812
*/
913

10-
/*
11-
@kotlin.annotation.Retention(AnnotationRetention.RUNTIME)
12-
@kotlin.annotation.Target(AnnotationTarget.FIELD, AnnotationTarget.ANNOTATION_CLASS)
13-
@ConstraintComposition
14-
@Constraint(validatedBy = [])
15-
@Size(min = 3, max = 60)
16-
annotation class NameSize(
17-
val message: String = "Invalid name.",
18-
val groups: Array<KClass<*>> = [],
19-
val payload: Array<KClass<*>> = [])
20-
*/
2114

2215
object Beans {
23-
16+
/**
17+
* Common bean when just a description is needed + default max description size
18+
*/
2419
const val DESCRIPTION_MAX = 255
2520

2621
open class Description {
2722

2823
@Size(max = DESCRIPTION_MAX)
2924
val description: String? = null
3025
}
26+
}
3127

28+
class ValidatedList<E> {
29+
/**
30+
* See my answer at https://stackoverflow.com/a/64060909
31+
*
32+
* By default, spring-boot cannot validate lists, as they are generic AND do not conform to the Java Bean definition.
33+
* This is one work-around: create a wrapper that fits the Java Bean definition, and use Jackson annotations to
34+
* make the wrapper disappear upon (de)serialization.
35+
* Do not change anything (such as making the _value field private) or it won't work anymore !
36+
*
37+
* Usage:
38+
* ```
39+
* @PostMapping("/something")
40+
* fun someRestControllerMethod(@Valid @RequestBody pojoList: ValidatedList<SomePOJOClass>)
41+
* ```
42+
*/
43+
44+
@JsonValue
45+
@Valid
46+
@NotNull
47+
@Size(min = 1, message = "array body must contain at least one item.")
48+
var _values: List<E>? = null
49+
50+
val values: List<E>
51+
get() = _values!!
52+
53+
@JsonCreator
54+
constructor(vararg list: E) {
55+
this._values = list.asList()
56+
}
3257
}
3358

3459

src/main/kotlin/ch/derlin/bbdata/common/exceptions/ExceptionAdviser.kt

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,11 @@ package ch.derlin.bbdata.common.exceptions
22

33
import io.swagger.v3.oas.annotations.Hidden
44
import org.hibernate.exception.ConstraintViolationException
5-
import org.springframework.boot.web.servlet.error.DefaultErrorAttributes
65
import org.springframework.dao.DataIntegrityViolationException
76
import org.springframework.http.HttpHeaders
87
import org.springframework.http.HttpStatus
98
import org.springframework.http.ResponseEntity
109
import org.springframework.http.converter.HttpMessageNotReadableException
11-
import org.springframework.stereotype.Component
1210
import org.springframework.validation.BindException
1311
import org.springframework.validation.FieldError
1412
import org.springframework.validation.ObjectError
@@ -20,6 +18,7 @@ import org.springframework.web.context.request.WebRequest
2018
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler
2119

2220

21+
2322
/**
2423
* date: 15.12.19
2524
* @author Lucy Linder <lucy.derlin@gmail.com>
@@ -37,17 +36,6 @@ open class ExceptionBody(open val exception: String, open val details: Any?) {
3736
}
3837
}
3938

40-
// for errors thrown at the server-level (404)
41-
@Component
42-
class ErrorAttributes : DefaultErrorAttributes() {
43-
override fun getErrorAttributes(webRequest: WebRequest, includeStackTrace: Boolean): Map<String, Any?> {
44-
val attrs = super.getErrorAttributes(webRequest, false)
45-
return mapOf(
46-
"exception" to attrs["error"],
47-
"details" to attrs["message"])
48-
}
49-
}
50-
5139
// for all other errors
5240
@RestControllerAdvice
5341
class GlobalControllerExceptionHandler : ResponseEntityExceptionHandler() {
@@ -78,6 +66,15 @@ class GlobalControllerExceptionHandler : ResponseEntityExceptionHandler() {
7866
@ExceptionHandler(DataIntegrityViolationException::class)
7967
fun handleDataIntegrityException(ex: DataIntegrityViolationException): ExceptionBody = ex.body()
8068

69+
/*
70+
@ExceptionHandler(javax.validation.ConstraintViolationException::class)
71+
@ResponseStatus(HttpStatus.BAD_REQUEST)
72+
@Hidden
73+
fun handleConstraintViolationException(ex: javax.validation.ConstraintViolationException) =
74+
// this one is thrown when validating a List<?> in parameters, if you do not use @ValidatedList
75+
// but the @Validated on the controller class + @Valid @NotNull @RequestBody newObjects: List<@Valid CLS>...
76+
ExceptionBody(ex.lastName(), ex.constraintViolations.map { it.propertyPath.toString() to it.message }.toMap())
77+
*/
8178

8279
// === unknown exceptions
8380

src/main/kotlin/ch/derlin/bbdata/common/exceptions/JsonErrorController.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package ch.derlin.bbdata.common.exceptions
22

33
import org.springframework.beans.factory.annotation.Autowired
4+
import org.springframework.boot.web.error.ErrorAttributeOptions
5+
import org.springframework.boot.web.servlet.error.DefaultErrorAttributes
46
import org.springframework.boot.web.servlet.error.ErrorController
57
import org.springframework.web.bind.annotation.RequestMapping
68
import org.springframework.web.bind.annotation.RestController
@@ -17,13 +19,17 @@ import javax.servlet.http.HttpServletResponse
1719
class JsonErrorController : ErrorController {
1820

1921
@Autowired
20-
private lateinit var errorAttributes: ErrorAttributes
22+
private lateinit var errorAttributes: DefaultErrorAttributes
2123

2224
@RequestMapping(ERROR_PATH)
2325
fun error(request: HttpServletRequest, response: HttpServletResponse): Map<String, Any?> {
2426
// Appropriate HTTP response code (e.g. 404 or 500) is automatically set by Spring.
2527
// Here we just define response body, which is forced to be JSON (vs whitelabel error page in browser)
26-
return errorAttributes.getErrorAttributes(ServletWebRequest(request), false)
28+
val attrs = errorAttributes.getErrorAttributes(ServletWebRequest(request), ErrorAttributeOptions.of(
29+
ErrorAttributeOptions.Include.MESSAGE, ErrorAttributeOptions.Include.BINDING_ERRORS))
30+
return linkedMapOf(
31+
"exception" to attrs["error"],
32+
"details" to attrs["message"])
2733
}
2834

2935
override fun getErrorPath(): String {

src/main/kotlin/ch/derlin/bbdata/input/InputController.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
package ch.derlin.bbdata.input
22

33
import ch.derlin.bbdata.HiddenEnvironmentVariables
4+
import ch.derlin.bbdata.common.ValidatedList
45
import ch.derlin.bbdata.common.cassandra.RawValueRepository
5-
import ch.derlin.bbdata.common.stats.StatsLogic
66
import ch.derlin.bbdata.common.exceptions.ForbiddenException
77
import ch.derlin.bbdata.common.exceptions.ItemNotFoundException
88
import ch.derlin.bbdata.common.exceptions.WrongParamsException
9+
import ch.derlin.bbdata.common.stats.StatsLogic
910
import ch.derlin.bbdata.output.api.types.BaseType
1011
import com.fasterxml.jackson.databind.ObjectMapper
1112
import io.swagger.v3.oas.annotations.Operation
@@ -45,6 +46,7 @@ class InputController(
4546

4647
private val MAX_LAG: Long = 2000 // in millis
4748

49+
4850
data class NewValueAugmented(
4951
val objectId: Long,
5052
val timestamp: DateTime,
@@ -75,15 +77,15 @@ class InputController(
7577
"Each objectId/timestamp couple must be unique, both in the body and the database. Hence, any duplicate will make the request fail. " +
7678
"If you omit to provide a timestamp for any measure, it will be added automatically (server time). " +
7779
"This request is *atomic*: either *all* measures are valid and saved, or none. ")
78-
fun postNewMeasures(@Valid @NotNull @RequestBody rawMeasures: List<NewValue>,
80+
fun postNewMeasures(@Valid @NotNull @RequestBody rawMeasures: ValidatedList<NewValue>,
7981
@RequestParam("simulate", defaultValue = "false") sim: Boolean): List<NewValueAugmented> {
8082

8183
val now = DateTime()
8284

8385
val augmentedJsons = mutableListOf<NewValueAugmented>()
8486
val valueKeys = mutableSetOf<String>()
8587

86-
val (measures, rawValues) = rawMeasures.map { rawMeasure ->
88+
val (measures, rawValues) = rawMeasures.values.map { rawMeasure ->
8789
val measure = if (rawMeasure.timestamp != null) {
8890
// check that date is in the past
8991
if (rawMeasure.timestamp.millis > now.millis + MAX_LAG) {

src/main/kotlin/ch/derlin/bbdata/input/InputControllerDeprecated.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package ch.derlin.bbdata.input
22

3+
import ch.derlin.bbdata.common.ValidatedList
34
import io.swagger.v3.oas.annotations.Operation
45
import io.swagger.v3.oas.annotations.tags.Tag
56
import org.springframework.web.bind.annotation.PostMapping
@@ -25,11 +26,11 @@ class InputControllerDeprecated(
2526
"Submit a new measure. This is similar to `/objects/values`, but takes only one measure in the body.")
2627
fun postNewMeasure(@Valid @NotNull @RequestBody rawMeasure: NewValue,
2728
@RequestParam("simulate", defaultValue = "false") sim: Boolean): InputController.NewValueAugmented =
28-
inputController.postNewMeasures(listOf(rawMeasure), sim)[0]
29+
inputController.postNewMeasures(ValidatedList(rawMeasure), sim)[0]
2930

3031
@PostMapping("input/measures/bulk")
3132
@Operation(description = "**DEPRECATED**: this endpoint may disappear in newer versions. Please use `/objects/values` instead.")
32-
fun postNewMeasureBulk(@Valid @NotNull @RequestBody rawMeasures: List<NewValue>,
33+
fun postNewMeasureBulk(@Valid @NotNull @RequestBody rawMeasures: ValidatedList<NewValue>,
3334
@RequestParam("simulate", defaultValue = "false") sim: Boolean): List<InputController.NewValueAugmented> =
3435
inputController.postNewMeasures(rawMeasures, sim)
3536

src/main/kotlin/ch/derlin/bbdata/output/api/objects/ObjectsController.kt

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ class ObjectsController(private val objectsAccessManager: ObjectsAccessManager,
6969
@PutMapping("")
7070
fun newObject(@UserId userId: Int,
7171
@RequestBody @Valid newObject: NewObject
72-
): Objects = newObjectBulk(userId, listOf(newObject))[0]
72+
): Objects = newObjectBulk(userId, ValidatedList(newObject))[0]
7373

7474

7575
@Protected(SecurityConstants.SCOPE_WRITE)
@@ -79,11 +79,10 @@ class ObjectsController(private val objectsAccessManager: ObjectsAccessManager,
7979
"**RESTRICTION**: all objects MUST have the SAME OWNER.")
8080
@PutMapping("/bulk")
8181
fun newObjectBulk(@UserId userId: Int,
82-
@RequestBody @Valid newObjects: List<NewObject>
82+
@Valid @NotNull @RequestBody newObjectsList: ValidatedList<NewObject>
8383
): List<Objects> {
84+
val newObjects = newObjectsList.values
8485

85-
if (newObjects.size == 0)
86-
throw WrongParamsException("object array is empty.")
8786
if (newObjects.any { it.owner != newObjects[0].owner })
8887
throw WrongParamsException("cannot create objects in bulk with different owners")
8988

src/main/kotlin/ch/derlin/bbdata/output/api/objects/ObjectsTokenController.kt

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package ch.derlin.bbdata.output.api.objects
22

33
import ch.derlin.bbdata.Constants
44
import ch.derlin.bbdata.common.Beans
5+
import ch.derlin.bbdata.common.ValidatedList
56
import ch.derlin.bbdata.common.exceptions.ItemNotFoundException
67
import ch.derlin.bbdata.common.exceptions.WrongParamsException
78
import ch.derlin.bbdata.output.api.CommonResponses
@@ -14,7 +15,6 @@ import io.swagger.v3.oas.annotations.Operation
1415
import io.swagger.v3.oas.annotations.tags.Tag
1516
import org.springframework.cache.CacheManager
1617
import org.springframework.http.ResponseEntity
17-
import org.springframework.validation.annotation.Validated
1818
import org.springframework.web.bind.annotation.*
1919
import javax.validation.Valid
2020
import javax.validation.constraints.Min
@@ -24,7 +24,6 @@ import javax.validation.constraints.Size
2424
* date: 23.12.19
2525
* @author Lucy Linder <lucy.derlin@gmail.com>
2626
*/
27-
@Validated
2827
@RestController
2928
@RequestMapping("/objects")
3029
@Tag(name = "Objects Tokens", description = "Manage object tokens")
@@ -75,7 +74,7 @@ class ObjectsTokenController(private val objectsAccessManager: ObjectsAccessMana
7574
@UserId userId: Int,
7675
@PathVariable(value = "objectId") objectId: Long,
7776
@Valid @RequestBody descriptionBody: Beans.Description?): Token =
78-
addObjectTokenBulk(userId, listOf(BulkTokenBody(objectId = objectId, description = descriptionBody?.description)))[0]
77+
addObjectTokenBulk(userId, ValidatedList(BulkTokenBody(objectId = objectId, description = descriptionBody?.description)))[0]
7978

8079

8180
@Protected(SecurityConstants.SCOPE_WRITE)
@@ -85,9 +84,9 @@ class ObjectsTokenController(private val objectsAccessManager: ObjectsAccessMana
8584
@PutMapping("bulk/tokens")
8685
fun addObjectTokenBulk(
8786
@UserId userId: Int,
88-
@Valid @NotNull @RequestBody tokenBodies: List<@Valid BulkTokenBody>): MutableList<Token> {
87+
@Valid @RequestBody tokenBodies: ValidatedList<BulkTokenBody>): MutableList<Token> {
8988

90-
tokenBodies.forEach {
89+
tokenBodies.values.forEach {
9190
// ensure rights
9291
val obj = objectsAccessManager.findById(it.objectId, userId, writable = true).orElseThrow {
9392
ItemNotFoundException("object ($it.objectId)")
@@ -96,7 +95,7 @@ class ObjectsTokenController(private val objectsAccessManager: ObjectsAccessMana
9695
throw WrongParamsException(msg = "Object $it.objectId is disabled.")
9796
}
9897

99-
return tokenRepository.saveAll(tokenBodies.map {
98+
return tokenRepository.saveAll(tokenBodies.values.map {
10099
Token.create(it.objectId, it.description)
101100
})
102101
}

0 commit comments

Comments
 (0)