0

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).

  1. 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)
  1. 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..

2
  • Nested validation for List<@Valid> elements isn't triggering because you need @Validated on the controller method's @ModelAttribute, not just @Valid. Also, after detecting validation errors, prefer staying on the same page instead of redirecting, so the BindingResult and form data are correctly available for error display. Commented Apr 29 at 4:33
  • Thank you @Vijay, adding @Validated before @ModelAttribute and not redirecting to another page, but staying on the same page (using Model to populate the template with the errors) solved the problems I was having. Commented May 1 at 17:14

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.