Form validator¶
The FormValidator is a class
used on the server side of an application to validate submitted forms. It runs all validations
defined in the form’s schema while supporting the specification of additional custom server-only
validations.
Let us use, as an example, the BusTripFormSchema shown in the Validations
section: a schema of a form for purchasing bus tickets which contains a few built-in validations. We
can create a basic form validator instance using it with:
A typical server-side form submission validation logic would then look like the following (where
submittedForm is an instance of the form model, often obtained by deserializing a request
initiated by a client application):
try {
val issues = formValidator.validate(submittedForm)
if (issues.containsNoErrors()) {
// Return success (e.g. 200)
} else {
// Return validation error (e.g. 400), typically including all issues
}
} catch (t: Throwable) {
// Return server error (e.g. 500), an error occurred during validation
}
Operations¶
The FormValidator provides
two main methods:
isValid: Whether the form contains no validation errors.validate: Returns all issues found in the form.
Both support validating only a subset of the form, specified via path, and whether to run internal (schema) and/or external (server-only) validations (see Run criteria below).
The issues returned by validate are a
Flow
of
LocatedValidationIssues,
which can be one of:
LocatedValidationError: validation error containing information about where the error occurred and other related details.LocatedValidationWarning: validation warning containing information about where the warning occurred and other related details.
KForm provides the following utility extension functions over both flows and iterables of
LocatedValidationIssues:
containsIssues: Whether the flow/iterable contains any issues (is not empty).containsNoIssues: Whether the flow/iterable contains no issues (is empty).containsErrors: Whether the flow/iterable contains at least one error.containsNoErrors: Whether the flow/iterable contains no errors.containsWarnings: Whether the flow/iterable contains at least one warning.containsNoWarnings: Whether the flow/iterable contains no warnings.
External validations¶
Let us say that we want to add a custom server-only validation to validate the domain of the submitted email address. This validation should read the set of blacklisted domains from an external context and can be written as:
object EmailDomainIsAllowed : Validation<String>() {
private val ValidationContext.blacklistedDomains: Set<String> by externalContext()
override fun ValidationContext.validate() = flow {
val domain = value.substringAfter('@')
if (domain in blacklistedDomains) {
emit(ValidationError("disallowedDomain"))
}
}
}
As shown, server-side validations (or external validations, as KForm calls them), are identical to those presented in the Validations section. The only difference being that, instead of being applied to the form’s schema, they are provided to the form validator itself:
val formValidator =
FormValidator(
BusTripFormSchema,
externalValidations = mapOf("/email" to listOf(EmailDomainIsAllowed)),
)
The form validator receives a map of external validations mapping the path of the field to validate to its list of external validations.
As an example, suppose that the submittedForm contains the following form model:
And that the set of blacklisted domains is stored in the following blacklistedDomains variable:
We can validate the form, providing the necessary external contexts, as follows:
val issues =
formValidator.validate(
submittedForm,
externalContexts = mapOf("blacklistedDomains" to blacklistedDomains),
)
Which produces a flow of issues containing the following error:
Run criteria¶
By default, when validating a form, the form validator runs all internal (schema) validations, followed by the external validations only if no errors were found in the internal ones. This is done on the assumption that external validations might be more “expensive” to run, requiring access to a database, or similar.
This behaviour can be tweaked by specifying the runCriteria parameter of the isValid or
validate methods to a value of the
ValidateRunCriteria
enum:
ExternalIfInternalValid: Runs all internal validations and, if no errors were found, follows with the external ones.InternalOnly: Runs only the internal (schema) validations.ExternalOnly: Runs only the external (server-only) validations.All: Always runs all validations.
Besides being useful for testing, controlling the run criteria by manually using InternalOnly
followed by ExternalOnly can be useful when intending to provide different external contexts for
internal/external validations.
For example, one can run all internal validations with a set of external contexts and, if no errors were found, query the database to obtain extra external contexts relevant only for the external validations: thus preventing unnecessary database access when a submitted form has internal-validation errors.
Handling warnings¶
Warnings are validation issues that should not prevent a form from being submitted. However, it is typically necessary to show them to the user. A common way of handling warnings is to have the user “accept” them, sending to the server, at submission time, the list of warnings already accepted by the user, allowing the server to reject a form submission containing warnings not yet accepted.
The server-side form submission validation logic would then look like the following, where
acceptedWarnings is a collection containing the codes of all warnings already accepted by the
user:
try {
val issues = formValidator.validate(submittedForm)
if (
issues.containsNoErrors() &&
acceptedWarnings.containsAll(issues.map { it.code }.toList())
) {
// Return success (e.g. 200)
} else {
// Return validation error (e.g. 400), typically including all issues
}
} catch (t: Throwable) {
// Return server error (e.g. 500), an error occurred during validation
}
Usage from Java¶
Because the FormValidator’s suspend/Flow-based methods are notoriously challenging to use from
Java, we provide a wrapper class named
AsyncFormValidator for
the purpose. This class provides the same
isValid and
validate methods,
but returning
CompletableFutures
instead.
An AsyncFormValidator may be initialised as follows:
AsyncFormValidator<BusTripForm> validator =
new AsyncFormValidator<>(
getBusTripFormSchema(),
Map.of("/email", List.of(new EmailDomainIsAllowed())));
Note
By default, the AsyncFormValidator runs all validations using the kotlinx.coroutines
Default
thread pool. This can be tweaked via the last (optional) argument of the
AsyncFormValidator constructor,
which takes an
Executor.
If your validations should run on the current thread, e.g. in order to access thread-local
context, you might want to provide Runnable::run as the executor instead:
For instructions on how to implement server-only validation in Java, see the Defining validations in Java section.
The previously introduced server-side form submission validation logic, including the handling of warnings, can be expressed in Java as follows:
try {
var issues =
validator
.validate(
submittedForm, Map.of("blacklistedDomains", blacklistedDomains))
.join();
if (ValidationIssues.containsNoErrors(issues)
&& acceptedWarnings.containsAll(
issues.stream().map(LocatedValidationIssue::getCode).toList())) {
// Return success (e.g. 200)
} else {
// Return validation error (e.g. 400), typically including all issues
}
} catch (Throwable t) {
// Return server error (e.g. 500), an error occurred during validation
}
As shown, the utility functions which check for the presence of issues, errors, or warnings in a
collection of LocatedValidationIssues are available as methods of the ValidationIssues class.