Validations¶
Validations can be provided to the schemas that define a form to validate its data. KForm offers a
set of built-in validations, while allowing users to write their own. All validations must be
instances of the abstract
Validation<T> class.
Consider the following model of a form for purchasing bus tickets, originally presented in a previous section:
data class BusTripForm(var email: String, var passengers: Table<Passenger>)
data class Passenger(var name: String, var age: Int?)
Suppose that we want to ensure that all fields are filled in, the email is valid, the number of passengers is between 1 and 10, and that passenger ages are between 0 and 100. We can define a schema that does this using the following build-in validations:
val BusTripFormSchema = ClassSchema {
BusTripForm::email { StringSchema(Required(), MatchesEmail()) }
BusTripForm::passengers {
TableSchema(Required(), MaxSize(10)) {
ClassSchema {
Passenger::name { StringSchema(Required()) }
Passenger::age { NullableSchema(Required()) { IntSchema(Min(0), Max(100)) } }
}
}
}
}
Note
You might notice that, when types are nullable, we can add validations to either the
NullableSchema itself, or to its “inner” schema (the IntSchema in the example above).
Validations applied to the inner schema are guaranteed to only execute when the value being
validated is not null.
On the other hand, validations applied to the NullableSchema will execute even when the value
being validated is null.
Validating the following form instance (e.g. via a form validator):
Would produce errors matching the following list:
listOf(
LocatedValidationError("/email", "emailPatternMismatch"),
LocatedValidationError("/passengers/1/name", "valueMissing"),
LocatedValidationError("/passengers/1/age", "valueMissing"),
)
Built-in validations¶
KForm offers the built-in validations listed below. For more information on each one, please consult
the API reference of each validation itself. All built-in validations allow overriding the
code and
severity of the emitted
issues:
Required: Applicable to values of any type. Ensures that a value is not missing, i.e. is notnull,false, or empty (strings, collections, etc.).NotEmpty: Applicable to values of any non-nullable type. Ensures that a value is not empty (strings, collections, files, etc.).NotBlank: Applicable to values of typeCharSequence(a supertype ofString). Ensures that a string is not blank, according to CharSequence.isBlank.MustEqual/MustNotEqual: Applicable to values of any type. Ensures that a value is equal to/different from the providedrequiredValue/forbiddenValueargument.OneOf/NotOneOf: Applicable to values of any type. Ensures that a value is/isn’t one of the providedallowedValues/disallowedValues.Min/Max/ExclusiveMin/ExclusiveMax: Applicable to comparable values. Ensures that a value is within bounds in respect to the provided argument.Length/MinLength/MaxLength: Applicable to values of typeCharSequence(a supertype ofString). Ensures that a string is exactly/at least/at most the provided length.Size/MinSize/MaxSize: Applicable to values with a size (collections, tables, files, etc.). Ensures that a value has exactly/at least/at most the provided size.Matches: Applicable to values of typeString. Ensures that a string matches the provided pattern argument.MatchesEmail: Applicable to values of typeString. Ensures that a string matches an email address pattern according to the W3C HTML5 spec.Scale: Applicable to values of typeBigDecimal. Ensures that a big decimal value’s scale is the one provided as argument.Accepts: Applicable to values of typeFile. Ensures that a file’s type is one of the providedacceptedFileTypes.UniqueItems/UniqueItemsBy: Applicable to collections, arrays, and tables. Ensures that all items in the collection/array/table are unique.
Writing your first validation¶
Suppose that we want to prevent submissions of forms where the email has some unwanted domains. No built-in validations provide this logic, so we must write a custom validation for doing so. Such a validation could be implemented as follows:
object EmailDomainIsAllowed : Validation<String>() {
private val BLACKLISTED_DOMAINS = setOf("example.com", "gmial.com", "test.com")
override fun ValidationContext.validate() = flow {
val domain = value.substringAfter('@')
if (domain in BLACKLISTED_DOMAINS) {
emit(ValidationError("disallowedDomain"))
}
}
}
It could be added to the schema of our email field with:
Note
We’ve used an object singleton to define our EmailDomainIsAllowed validation because the
validation doesn’t use any parameters. If it did, we could have used a class instead.
All built-in validations are classes because they can allow the customisation of their emitted
issues’ code and severity.
EmailDomainIsAllowed extends Validation<String>, meaning that it can be applied to any string
schema, and overrides the
ValidationContext.validate
method.
The ValidationContext is
the context available while a validation is being executed: within it, value can be accessed to
get the value being validated. Other properties such as the path and schema of the validated
field can also be accessed.
The
ValidationContext.validate
method must return a
Flow
(an asynchronous stream) of
validation issues. Use the
flow
builder function together with
emit
to indicate issues with the form data, which can be one of two types:
ValidationError: a validation issue which prevents form submission.ValidationWarning: a validation issue which does not prevent form submission.
Validation issues always have a mandatory
code which is used to
identify the validation rule that was violated. Further properties such as a message or other
details can be provided as a map of
data to the issue.
Depending on other values¶
Suppose we want to add trip dates to our form. We can add a mandatory departure date and an optional return date (for when the trip is one-way only) via the following updated model and schema:
data class BusTripForm(
var departureDate: LocalDate?,
var returnDate: LocalDate?,
// …
)
// …
val BusTripFormSchema = ClassSchema {
BusTripForm::departureDate { NullableSchema(Required()) { LocalDateSchema() } }
BusTripForm::returnDate { NullableSchema { LocalDateSchema() } }
// …
}
In this scenario, we should ensure that, when a return date is provided, it is not before the departure date. Such validation could be implemented as follows:
object ValidReturnDate : Validation<LocalDate>() {
private val ValidationContext.departureDate: LocalDate? by dependency("/departureDate")
override fun ValidationContext.validate() = flow {
if (departureDate != null && value < departureDate!!) {
emit(ValidationError("returnDateBeforeDeparture"))
}
}
}
And added to the returnDate’s field schema with:
Validations can have as many dependencies as needed. Dependencies are typically declared as
delegated properties over a
ValidationContext
receiver. Meaning that the value of the
dependency can be accessed while the validation is being executed with a context.
Dependencies can be declared via the following delegated properties:
-
dependency(throws an exception if no value exists at the specified path): -
dependencyOrNull(returnsnullif no value exists at the specified path):
The path provided to dependency/dependencyOrNull is relative to the path of the field being
validated. Instead of the absolute /departureDate, we could also have used ../departureDate,
since our validation is being applied to a schema at /returnDate.
Dependency paths must not contain any wildcards, except for an optional trailing recursive wildcard
(**) denoting that the validation should also depend on all descendants of the dependency, for
example: /passengers/** (more on this in the next section).
The declared dependency types are validated at runtime once the validations are initialised within
their schemas (e.g. in our example, if we’d declared the type of the departureDate dependency as
Int?, an error would be thrown, at runtime, while initialising a
form validator or form manager with the
BusTripFormSchema).
Validation dependencies are declared explicitly so that they can be used as validation metadata, allowing the form manager to cache their result and only reevaluate them when values they depend on change.
Depending on descendants¶
Elaborating on the previous section, it is worth noting that all validations have an implicit
dependency on ., i.e. on the value being validated, accessible as value within the validation
context.
Suppose that we want to ensure that at least one of the passengers in the trip is an adult. We may try to implement a validation on the passengers’ table as follows:
object HasAdultPassenger : Validation<Table<Passenger>>() {
override fun ValidationContext.validate() = flow {
if (value.size > 0) {
for (passenger in value.values) {
if (passenger.age != null && passenger.age!! >= 18) {
return@flow
}
}
emit(ValidationError("noAdultPassengers"))
}
}
}
However, if we actually try to use this validation within a form manager, we will
notice that the validation status of the passengers table will sometimes be wrong: in particular,
editing the age of passengers will not cause the validation to be reevaluated; the validation
will only rerun as passengers are added to or removed from the table. This happens because the
validation has an implicit dependency on ., in this case, on /passengers, without also depending
on its descendants.
To fix this, set the
dependsOnDescendants
property of the validation to true:
object HasAdultPassenger : Validation<Table<Passenger>>() {
override val dependsOnDescendants = true
// …
This effectively changes the implicit dependency of the validation from . to ./**, meaning that
the validation will not only depend on the value being validated, but also on all its descendants,
being reevaluated whenever any of them change.
As previously hinted at, the same care must be taken when declaring explicit dependencies on other
fields. Suppose that we want to add a validation to the /departureDate field which forbids Sunday
trips from having any children. For the same reason, we would need to declare a dependency on
/passengers/** rather than /passengers to ensure that the validation is reevaluated whenever the
age of passengers changes.
Tip
You don’t need to depend on the descendants of a value (be it the field being currently validated or a field being depended upon) when:
- The value is represented by a schema which does not contain other schemas.
- You’re only interested in knowing if the value exists or not (i.e. if the check is
something like
value != null). - The value is a collection (e.g. list or table) and you’re only interested in its size but
not in its content (i.e. if the check is something like
value.isEmpty()orvalue.size < 10).
Depending on external context¶
Besides depending on other values of the form, validations may also depend on data external to the form itself. Suppose that we want to forbid trips from being booked on days that have already been fully booked. Information on whether a day is already fully booked is “external” to the form, so it must be provided by other means: KForm calls this type of data “external contexts”.
External contexts are provided to KForm as a map of strings to arbitrary data: each key in this map represents the “name” of its external context.
Validations may declare dependencies on external contexts by referencing said names. Assuming that
the set of fully booked days is provided as an external context named fullyBookedDays, we can
implement the previously mentioned validation as follows:
object NotFullyBooked : Validation<LocalDate?>() {
private val ValidationContext.fullyBookedDays: Set<LocalDate> by externalContext()
override fun ValidationContext.validate() = flow {
if (value != null && value in fullyBookedDays) {
emit(ValidationError("dayIsFullyBooked"))
}
}
}
External context dependencies can be declared via the following delegated properties (when an
externalContextName is not provided as an argument, the external context name defaults to the name
of the property itself):
-
externalContext(throws an exception if no external context was found with the provided name):or:
-
externalContextOrNull(returnsnullif no external context was found with the provided name):or:
Scoped validations¶
In some scenarios, we might want to present a form in multiple “modes”: forms that are mostly similar, but with some variations. For example, we might want to have slight variations for our example bus trip form depending on the country where the bus trip is taking place: in this scenario, we might want to enable or disable certain validations depending on the “scope” the validation is being evaluated under.
KForm provides
ScopedValidations
for this purpose. Scoped validations automatically depend on an external context (named "scope",
by default) and only evaluate if the validation is allowed according to their specified scopes.
Suppose that our form may run in two different scopes, represented by the following enum:
If our business logic mandates that passenger age is only mandatory for EU trips, we may scope the
age field’s Required validation as follows:
The above validation will only be evaluated if the value of the scope external context is
LocationScope.EU.
Allowed validation scopes can be defined via one of:
ValidationScopes.OneOf: validation will run if its scope is one of the provided values.ValidationScopes.NotOneOf: validation will run if its scope is not one of the provided values.
Custom scoped validations may also be defined via extension. The following example defines a scoped validation which forbids trips from being booked on Sundays in the EU:
object ValidateSundayTrip : ScopedValidation<LocalDate?, LocationScope>() {
override val scopes = ValidationScopes.OneOf(LocationScope.EU)
override fun ValidationContext.scopedValidate() = flow {
if (value != null && value!!.dayOfWeek == DayOfWeek.SUNDAY) {
emit(ValidationError("invalidSundayTrip"))
}
}
}
The allowed scopes for this validation are defined via the
scopes
property and the validation logic is defined via the
ValidationContext.scopedValidate
method.
Custom scoped validations are otherwise identical to regular validations, supporting dependencies over values or external contexts.
Defining validations in Java¶
KForm supports validations defined only on the server-side (which are then provided to a form validator). If your server application is written in Java, you might have to define validations in Java instead of Kotlin.
Because it isn’t trivial to use certain Kotlin constructs or classes from Java (such as Flows), KForm provides a few helper classes to make it easier to define validations in Java:
- SyncValidation: Synchronous validation more easily implemented from Java.
- AsyncValidation: Asynchronous validation more easily implemented from Java.
- ScopedSyncValidation: Scoped synchronous validation more easily implemented from Java.
- ScopedAsyncValidation: Scoped asynchronous validation more easily implemented from Java.
For reference, we list below (roughly equivalent) Java implementations of all validations previously implemented on this page:
static class EmailDomainIsAllowed extends SyncValidation<String> {
private static final Set<String> BLACKLISTED_DOMAINS =
Set.of("example.com", "gmial.com", "test.com");
@Override
public void validate(ValidationContext ctx, List<ValidationIssue> issues) {
String value = ctx.value();
String domain = value.substring(value.indexOf('@') + 1);
if (BLACKLISTED_DOMAINS.contains(domain)) {
issues.add(new ValidationError("disallowedDomain"));
}
}
}
static class ValidReturnDate extends SyncValidation<LocalDate> {
public ValidReturnDate() {
addDependency("departureDate", "/departureDate");
}
@Override
public void validate(ValidationContext ctx, List<ValidationIssue> issues) {
LocalDate value = ctx.value();
LocalDate departureDate = ctx.dependency("departureDate");
if (departureDate != null && value.compareTo(departureDate) < 0) {
issues.add(new ValidationError("returnDateBeforeDeparture"));
}
}
}
static class HasAdultPassenger extends SyncValidation<Table<Passenger>> {
public HasAdultPassenger() {
setDependsOnDescendants(true);
}
@Override
public void validate(ValidationContext ctx, List<ValidationIssue> issues) {
Table<Passenger> value = ctx.value();
if (value.getSize() > 0) {
for (Passenger passenger : value.getValues()) {
if (passenger.getAge() != null && passenger.getAge() >= 18) {
return;
}
}
issues.add(new ValidationError("noAdultPassengers"));
}
}
}
static class NotFullyBooked extends SyncValidation<LocalDate> {
public NotFullyBooked() {
addExternalContextDependency("fullyBookedDays");
}
@Override
public void validate(ValidationContext ctx, List<ValidationIssue> issues) {
LocalDate value = ctx.value();
Set<LocalDate> fullyBookedDays = ctx.externalContext("fullyBookedDays");
if (value != null && fullyBookedDays.contains(value)) {
issues.add(new ValidationError("dayIsFullyBooked"));
}
}
}
static class ValidateSundayTrip extends ScopedSyncValidation<LocalDate, LocationScope> {
public ValidateSundayTrip() {
setScopes(new ValidationScopes.OneOf<>(LocationScope.EU));
}
@Override
public void scopedValidate(ValidationContext ctx, List<ValidationIssue> issues) {
LocalDate value = ctx.value();
if (value != null
&& ConvertersKt.toJavaLocalDate(value).getDayOfWeek() == DayOfWeek.SUNDAY) {
issues.add(new ValidationError("invalidSundayTrip"));
}
}
}
The biggest differences from Kotlin-defined validations are the following:
- Dependencies and other validation metadata should be defined in the constructor via:
addDependency: Adds a new dependency with the provided key and path to the validation.addExternalContextDependency: Adds a new externa context dependency with the provided name to the validation.setDependsOnDescendants: Marks the validation as depending on its descendants.setScopes: Sets the allowed scopes for the scoped validation.
- The value being validated, as well as value of dependencies or external contexts needs to be
obtained from the
validation context more
explicitly. This is typically done at the start of the validate method via:
value: Gets the value being validated.dependency/dependencyOrNull: Obtains the value of the dependency with the key previously declared.externalContext/externalContextOrNull: Obtains the value of the external context with the name previously declared.