Tutorials/Validate input

Validate input end to end

A form posts an owner, and the client should learn exactly what is wrong with it. Each rule has one place to live and is checked there, once, so nothing is written down twice.

The Storm way6 min readKotlin and Java

01The task

A POST /owners creates an owner. A name is required, a telephone number holds digits, a name fits its column, and no two owners share a number. Those are four rules of different kinds, and each is checked by whatever knows the answer.

02The table and the entity

The schema already states the limits and the unique constraint:

V1__owner.sql SQL
1
2
3
4
5
6
7
CREATE TABLE owner (
    id         INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    first_name VARCHAR(30) NOT NULL,
    last_name  VARCHAR(30) NOT NULL,
    telephone  VARCHAR(20) NOT NULL,
    CONSTRAINT owner_telephone_uk UNIQUE (telephone)
);

The entity adds what must hold for every owner, wherever it comes from. The check is in the constructor, so an owner without a first name cannot exist:

Owner.kt Kotlin · Storm
1
2
3
4
5
6
7
8
9
10
data class Owner(
    @PK val id: Int = 0,
    val firstName: String,
    val lastName: String,
    @UK val telephone: String,
) : Entity<Int> {
    init {
        require(firstName.isNotBlank()) { "An owner has a first name." }
    }
}
1
2
3
4
5
6
7
8
9
10
public record Owner(@PK Integer id,
                    String firstName,
                    String lastName,
                    @UK String telephone) implements Entity<Integer> {
    public Owner {
        if (firstName.isBlank()) {
            throw new IllegalArgumentException("An owner has a first name.");
        }
    }
}

The constructor runs on reads as well, so an invariant has to be true of the rows you already have.

03Product rules on the request type

A required field and a format are decisions about this form. They belong to the request type and to the framework's own validator, which rejects the request before your code runs:

OwnerForm.kt Kotlin · Spring
1
2
3
4
5
data class OwnerForm(
    @field:NotBlank val firstName: String,
    @field:NotBlank val lastName: String,
    @field:Pattern(regexp = "[0-9 +-]+") val telephone: String,
)
1
2
3
public record OwnerForm(@NotBlank String firstName,
                        @NotBlank String lastName,
                        @Pattern(regexp = "[0-9 +-]+") String telephone) {}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Serializable
data class OwnerForm(val firstName: String, val lastName: String, val telephone: String)

fun Application.validateOwnerForms() {
    install(RequestValidation) {
        validate<OwnerForm> { form ->
            when {
                form.firstName.isBlank() || form.lastName.isBlank() -> ValidationResult.Invalid("A name is required.")
                !form.telephone.matches(Regex("[0-9 +-]+")) -> ValidationResult.Invalid("A telephone number holds digits.")
                else -> ValidationResult.Valid
            }
        }
    }
}

There is no length here. The column says 30, and a second copy of that number could only come to disagree with it. A limit stricter than the column would be a product rule, and it would go here.

04Column limits from the schema

Build the entity, then check it against its columns:

OwnerController.kt Kotlin · Spring
1
2
3
4
5
6
7
8
9
10
11
@RestController
class OwnerController(private val owners: OwnerRepository) {

    @PostMapping("/owners")
    @ResponseStatus(HttpStatus.CREATED)
    fun create(@Valid @RequestBody form: OwnerForm): Owner {
        val owner = Owner(firstName = form.firstName, lastName = form.lastName, telephone = form.telephone)
        owners.validateOrThrow(owner)
        return owners.insertAndFetch(owner)
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestController
public class OwnerController {

    private final OwnerRepository owners;

    public OwnerController(OwnerRepository owners) {
        this.owners = owners;
    }

    @PostMapping("/owners")
    @ResponseStatus(HttpStatus.CREATED)
    public Owner create(@Valid @RequestBody OwnerForm form) {
        var owner = new Owner(null, form.firstName(), form.lastName(), form.telephone());
        owners.validateOrThrow(owner);
        return owners.insertAndFetch(owner);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
fun Application.ownerRoutes() {
    val owners: OwnerRepository by dependencies
    routing {
        post("/owners") {
            val form = call.receive<OwnerForm>()
            val owner = Owner(firstName = form.firstName, lastName = form.lastName, telephone = form.telephone)
            owners.validateOrThrow(owner)
            val created = owners.insertAndFetch(owner)
            call.respond(HttpStatusCode.Created, mapOf("id" to created.id))
        }
    }
}

validateOrThrow compares the values against the schema Storm loaded at startup and throws every violation it finds, without a statement. It is optional: the database would refuse the same value, one at a time. Call it when a form should report all its problems at once.

05The duplicate

The route has no query asking whether the number is taken. Two requests could both get the answer no and both insert. The unique constraint lets exactly one through, so the insert runs and the database decides. Storm reads the refusal and states it in terms of the entity: a Unique violation on the telephone field.

06The answer to the client

A violation has the same shape whether validateOrThrow found it or the database reported it, and the status says whose fault it is: a conflict with other rows is a 409, and a value that breaks a rule of its own is a 400. With the Spring Boot starter the status needs no code at all, and a handler only adds the fields. In Ktor, one line answers with both:

ViolationHandler.kt Kotlin · Spring
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
data class FieldError(val fields: List<String>, val kind: String?, val maxLength: Int?)

@RestControllerAdvice
class ViolationHandler {

    @ExceptionHandler(DataIntegrityViolationException::class)
    fun handle(exception: DataIntegrityViolationException): ProblemDetail {
        val cause = exception.cause
        if (exception !is ErrorResponse || cause !is IntegrityViolationException) {
            throw exception
        }
        return exception.body.apply {
            setProperty("errors", cause.violations().map { violation ->
                FieldError(violation.fields().map { it.fieldPath() }, violation::class.simpleName, (violation as? Length)?.maxLength())
            })
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public record FieldError(List<String> fields, String kind, @Nullable Integer maxLength) {

    static FieldError of(IntegrityViolation violation) {
        return new FieldError(
                violation.fields().stream().map(Metamodel::fieldPath).toList(),
                violation.getClass().getSimpleName(),
                violation instanceof Length length ? length.maxLength() : null);
    }
}

@RestControllerAdvice
class ViolationHandler {

    @ExceptionHandler(DataIntegrityViolationException.class)
    ProblemDetail handle(DataIntegrityViolationException exception) {
        if (!(exception instanceof ErrorResponse response)
                || !(exception.getCause() instanceof IntegrityViolationException cause)) {
            throw exception;
        }
        ProblemDetail problem = response.getBody();
        problem.setProperty("errors", cause.violations().stream().map(FieldError::of).toList());
        return problem;
    }
}
1
2
3
4
5
6
7
8
fun Application.violationPages() {
    install(StatusPages) {
        stormIntegrityViolations()
        exception<RequestValidationException> { call, cause ->
            call.respond(HttpStatusCode.BadRequest, cause.reasons)
        }
    }
}

With the Spring Boot starter, the exception arrives as Spring's DataIntegrityViolationException, or its subclass DuplicateKeyException for a Unique violation, with Storm's exception as the cause. A service catches those types as usual.

07What the client receives

The form sentWho answeredResponse
A blank first nameThe request type400 from the framework's validator
A telephone number with lettersThe request type400 from the framework's validator
A first name of 31 charactersvalidateOrThrow400, with fields: ["firstName"], kind: "Length" and maxLength: 30
A number another owner hasThe database409, with fields: ["telephone"] and kind: "Unique"

No response names a table, a constraint or a statement.

08Keep going

The reference documentation covers the details: