I am following a course at my college where we build a simple SpringBoot application. This task is about validation. I handle the data exchange with several DTOs (for creating, updating and response).
- The validation for the DTO collection isn't working properly:
- it validates the size of the collection and other properties like title and description
- it doesn't validate the contents of the collection (f.e. if a DTO property has a correct size)
- it only validates the collection for the RestController, not for the Controller (at this point, any input in the HTML form only produces a page reload/ redirect)
- The errors aren't displayed in the HTML or the response body:
- they only show in the terminal and only for the
package de.thk.gm.fddw.lecturefaq.controllers
import de.thk.gm.fddw.lecturefaq.models.poll_dtos.CreatePollRequestDTO
import de.thk.gm.fddw.lecturefaq.models.poll_dtos.PollResponseDTO
import de.thk.gm.fddw.lecturefaq.models.poll_dtos.UpdatePollRequestDTO
import de.thk.gm.fddw.lecturefaq.services.PollsService
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.*
import org.springframework.web.server.ResponseStatusException
import java.util.*
@RestController
@RequestMapping("/api/v1")
class PollsRestController(
private val pollsService: PollsService
) {
@PostMapping("/users/{userId}/polls")
@ResponseStatus(HttpStatus.CREATED)
fun createPoll(
@PathVariable userId: UUID,
@Valid @RequestBody poll: CreatePollRequestDTO,
): PollResponseDTO {
try {
val createdPoll = pollsService.save(poll, userId)
return createdPoll
} catch (e: ResponseStatusException) {
throw e
} catch (e: Exception) {
throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not create poll")
}
}
@DeleteMapping("/users/{userId}/polls/{pollId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun deletePoll(
@PathVariable pollId: UUID,
@PathVariable userId: UUID
) {
try {
pollsService.removeById(pollId)
} catch (e: Exception) {
throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not delete poll")
}
}
}
package de.thk.gm.fddw.lecturefaq.controllers
import de.thk.gm.fddw.lecturefaq.models.poll_dtos.CreatePollRequestDTO
import de.thk.gm.fddw.lecturefaq.services.AnswersService
import de.thk.gm.fddw.lecturefaq.services.PollsService
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.validation.BindingResult
import org.springframework.web.bind.annotation.*
import org.springframework.web.server.ResponseStatusException
import org.springframework.web.servlet.mvc.support.RedirectAttributes
import java.util.*
@Controller
@RequestMapping(produces = [MediaType.TEXT_HTML_VALUE])
class PollsController(
private val pollsService: PollsService,
private val answersService: AnswersService
) {
@PostMapping("/users/{userId}/polls/create-form")
fun createPollFromForm(
@PathVariable userId: UUID,
@ModelAttribute @Valid poll: CreatePollRequestDTO, //TODO: Fix validation for contents of answers list
bindingResult: BindingResult,
redirectAttributes: RedirectAttributes
): String {
try {
if (bindingResult.hasErrors()) {
redirectAttributes.addFlashAttribute("errors", bindingResult)
return "redirect:/app/users/${userId}/polls/create-form"
} else {
val poll = pollsService.save(poll, userId)
return "redirect:/app/users/${userId}/polls/${poll.id}"
}
} catch (e: ResponseStatusException) {
throw e
} catch (e: Exception) {
throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not create poll")
}
}
}
package de.thk.gm.fddw.lecturefaq.models.poll_dtos
import de.thk.gm.fddw.lecturefaq.constants.*
import de.thk.gm.fddw.lecturefaq.models.answer_dtos.CreateAnswerRequestDTO
import jakarta.validation.Valid
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.NotEmpty
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size
class CreatePollRequestDTO(
@field:NotNull
@field:NotBlank
@field:Size(min = MINIMUM_TITLE_LENGTH, max = MAXIMUM_TITLE_LENGTH)
val title: String,
@field:NotNull
@field:NotBlank
@field:Size(min = MINIMUM_DESCRIPTION_LENGTH, max = MAXIMUM_DESCRIPTION_LENGTH)
val description: String,
@field:NotNull
@field:NotEmpty(message = "answers must not be empty")
@field:Size(min = MINIMUM_ANSWERS_COUNT, max = MAXIMUM_ANSWERS_COUNT)
var answers: MutableList<@Valid CreateAnswerRequestDTO> = mutableListOf()
)
package de.thk.gm.fddw.lecturefaq.models.answer_dtos
import de.thk.gm.fddw.lecturefaq.constants.MAXIMUM_TEXT_LENGTH
import de.thk.gm.fddw.lecturefaq.constants.MINIMUM_TEXT_LENGTH
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size
import java.util.*
class CreateAnswerRequestDTO(
val pollId: UUID? = null,
@field:NotNull
@field:NotBlank
@field:Size(min = MINIMUM_TEXT_LENGTH, max = MAXIMUM_TEXT_LENGTH)
val text: String
)
package de.thk.gm.fddw.lecturefaq.services
import de.thk.gm.fddw.lecturefaq.models.answer_dtos.CreateAnswerRequestDTO
import de.thk.gm.fddw.lecturefaq.models.poll_dtos.CreatePollRequestDTO
import de.thk.gm.fddw.lecturefaq.models.poll_dtos.PollResponseDTO
import de.thk.gm.fddw.lecturefaq.models.poll_dtos.UpdatePollRequestDTO
import de.thk.gm.fddw.lecturefaq.repositories.PollsRepository
import de.thk.gm.fddw.lecturefaq.repositories.UsersRepository
import de.thk.gm.fddw.lecturefaq.util.AnswersDTOMapper
import de.thk.gm.fddw.lecturefaq.util.PollsDTOMapper
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.*
@Service
class PollsServiceImpl(
private val pollsRepository: PollsRepository,
private val pollsDTOMapper: PollsDTOMapper,
private val usersRepository: UsersRepository,
private val answersDTOMapper: AnswersDTOMapper
) : PollsService {
@Transactional
override fun save(poll: CreatePollRequestDTO, userId: UUID): PollResponseDTO {
val user = usersRepository.findById(userId)
.orElseThrow { NoSuchElementException("User for this poll not found") }
val newPoll = pollsDTOMapper.mapToNewPoll(poll, user)
val answers = poll
.answers
.filter { it.text.isNotBlank() } //TODO: Should be validated in controller
// -> this didn't work yet (probably issue with nested DTO inside DTO)
.map { answer ->
answersDTOMapper.mapToNewAnswer(
CreateAnswerRequestDTO(
pollId = newPoll.id,
text = answer.text
),
newPoll
)
}
newPoll.answers.addAll(answers)
val savedPoll = pollsRepository.save(newPoll)
return pollsDTOMapper.mapToPollResponse(savedPoll)
}
}
package de.thk.gm.fddw.lecturefaq.services
import de.thk.gm.fddw.lecturefaq.models.Answer
import de.thk.gm.fddw.lecturefaq.models.Poll
import de.thk.gm.fddw.lecturefaq.models.answer_dtos.AnswerResponseDTO
import de.thk.gm.fddw.lecturefaq.models.answer_dtos.CreateAnswerRequestDTO
import de.thk.gm.fddw.lecturefaq.models.answer_dtos.UpdateAnswerRequestDTO
import de.thk.gm.fddw.lecturefaq.repositories.AnswersRepository
import de.thk.gm.fddw.lecturefaq.repositories.PollsRepository
import de.thk.gm.fddw.lecturefaq.util.AnswersDTOMapper
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.*
@Service
class AnswersServiceImpl(
private val pollsRepository: PollsRepository,
private val answersRepository: AnswersRepository,
private val answersDTOMapper: AnswersDTOMapper
) : AnswersService {
@Transactional
override fun save(answer: CreateAnswerRequestDTO): AnswerResponseDTO {
val poll = pollsRepository.findById(answer.pollId!!)
.orElseThrow { NoSuchElementException("Poll for this answer not found") }
val newAnswer = answersDTOMapper.mapToNewAnswer(answer, poll)
val savedAnswer = answersRepository.save(newAnswer)
return answersDTOMapper.mapToAnswerResponse(savedAnswer)
}
}
<#import "../base-layout-lecturer.ftlh" as base>
<@base.layout>
<h1>Multiple-Choice Fragen stellen</h1>
<form action="/app/users/${userId}/polls/create-form" method="post">
<input type="text" name="title" placeholder="Titel">
<#if errors?? && errors.getFieldError("title")??>
${errors.getFieldError("title")["defaultMessage"]}
</#if>
<input type="text" name="description" placeholder="Beschreibung... (Optional)">
<#if errors?? && errors.getFieldError("description")??>
${errors.getFieldError("description")["defaultMessage"]}
</#if>
<input type="text" name="answers[0]" placeholder="Antwort A">
<#if errors?? && errors.getFieldError("answers[0]")??>
${errors.getFieldError("answers[0].text")["defaultMessage"]}
</#if>
<input type="text" name="answers[1]" placeholder="Antwort B">
<#if errors?? && errors.getFieldError("answers[1]")??>
${errors.getFieldError("answers[1].text")["defaultMessage"]}
</#if>
<input type="text" name="answers[2]" placeholder="Antwort C">
<#if errors?? && errors.getFieldError("answers[2]")??>
${errors.getFieldError("answers[2].text")["defaultMessage"]}
</#if>
<input type="text" name="answers[3]" placeholder="Antwort D">
<#if errors?? && errors.getFieldError("answers[3]")??>
${errors.getFieldError("answers[3].text")["defaultMessage"]}
</#if>
<button type="submit">Abschicken</button>
</form>
</@base.layout>
plugins {
id 'org.springframework.boot' version '3.3.0'
id 'io.spring.dependency-management' version '1.1.5'
id 'org.jetbrains.kotlin.plugin.jpa' version '1.9.24'
id 'org.jetbrains.kotlin.jvm' version '1.9.24'
id 'org.jetbrains.kotlin.plugin.spring' version '1.9.24'
}
group = 'de.thk.gm.fddw'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter")
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-freemarker'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin'
implementation 'org.jetbrains.kotlin:kotlin-reflect'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.json:org.json:chargebee-1.0'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'org.postgresql:postgresql'
runtimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.jetbrains.kotlin:kotlin-test-junit5'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation group: 'com.chargebee', name: 'chargebee-java', version: '3.31.0'
}
kotlin {
compilerOptions {
freeCompilerArgs.addAll '-Xjsr305=strict'
}
}
tasks.named('test') {
useJUnitPlatform()
}
- I tried following this Baeldung tutorial, basically changing @Valid to @Validated. I expected Spring to validate all the contents of the list recursively, but it didn't happen.
- validating
var answers: MutableList<@Valid String>? = mutableListOf()(and handling DTO construction after that) - exchanging class for data class
- initializing the DTO via secondary or primary construtor
- making all properties nullable like suggested here
TL:DR:
- So far I don't get a Bad Request for failed validation of collection elements.
Actually, I tried all kinds of combinations, combining @Valid and @Validated, I couldn't tell them all... at this point it's n * m combinations and I can't recall them all..
List<@Valid>elements isn't triggering because you need@Validatedon the controller method's@ModelAttribute, not just@Valid. Also, after detecting validation errors, prefer staying on the same page instead of redirecting, so theBindingResultand form data are correctly available for error display.@Validatedbefore@ModelAttributeand not redirecting to another page, but staying on the same page (usingModelto populate the template with the errors) solved the problems I was having.