-
Notifications
You must be signed in to change notification settings - Fork 2
Validation
Tip
This page is written so that later sections build upon earlier ones. For users not familiar with the features yet it is recommended to read it start to finish.
Most of the validations specified by the JSON schema are supported. However, since annotations are used to add validation to a property method some of the validations work slightly different than specified so that each targets the annotated method's value. For example required is specified on each required property instead of specifying a list of required properties on their parent. This is also to avoid string name references to be required that are not safe to refactor.
Validations include:
Target | JSON schema |
@Validation attribute |
Description |
---|---|---|---|
(any) | type |
type() |
annotated property must have on of the JSON node types (with INTEGER being a distinct type) |
(any) |
enum /const
|
oneOfValues() + enumeration()
|
annotated property's value must be one of a given set of JSON values or the given enum constant names |
(any) | required |
required() or use @Required
|
annotated property must be present (unconditionally), that means defined and non-null
|
(any) | dependentRequired |
dependentRequired() |
annotated property must be present (conditionally). Conditions are modeled using named groups and roles in this group (details below) |
STRING |
minLength |
minLength() |
annotated text property must have a minimum length, this implicitly makes it required unless required explicitly states NO
|
STRING |
maxLength |
maxLength() |
annotated text property must not be longer than the maximum length (inclusive) |
STRING |
pattern |
pattern() |
annotated text property must match the given regex pattern |
NUMBER |
minimum |
minimum() |
annotated number property must be larger than or equal to the minimum value |
NUMBER |
maximum |
maximum() |
annotated number property must be less than or equal to the maximum value |
NUMBER |
exclusiveMinimum |
exclusiveMinimum() |
annotated number property must be larger than the minimum value |
NUMBER |
exclusiveMaximum |
exclusiveMaximum() |
annotated number property must be less than the maximum value |
NUMBER |
multipleOf |
multipleOf() |
annotated number property must dividable by the given number without rest |
ARRAY |
minItems |
minItems() |
annotated array property must have at least the number of elements, this implicitly makes it required unless required explicitly states NO
|
ARRAY |
maxItems |
maxItems() |
annotated array property must have at most the number of elements |
ARRAY |
uniqueItems |
uniqueItems() |
annotated array property must not have any duplicates (same "normalized" JSON) |
OBJECT |
minProperties |
minProperties() |
annotated object property must have at least the number of members, this implicitly makes it required unless required explicitly states NO
|
OBJECT |
maxProperties |
maxProperties() |
annotated object property must have at most the number of members |
Note
If a validation for a target type is used but that type does not apply based on the return type analysis or the type(s) declared via type()
the validation has no effect as it does not apply to any valid value for the property.
The primary way to add validation to a property is to annotate the method with @Validation
and use one or more of the annotation attributes to add validations.
interface User extends JsonObject {
@Validation(pattern = "[a-z]+", minLength=8, maxLength=20)
default String username() {
return getString("username").string();
}
}
Alternatively to @Validation
meta-annotations can be used.
These are annotations that themselves are annotated with @Validation
.
@Required
is an example of such a meta-annotation.
Users can easily define their own.
For example,
@Target( ElementType.METHOD )
@Retention( RetentionPolicy.RUNTIME )
@Validation( required = NO )
public @interface Optional {}
An annotation like @Optional
can make sense since minLength
, minItems
and minProperties
all make a property implicitly required.
That means they are required as long as their required
evaluates to AUTO
(the default).
With an @Optional
annotation like above user then can explicitly declare it non-required.
All properties that use primitive return types are also considered implicitly required. But they might use a default so they are actually not required, for example:
interface User extends JsonObject {
@Optional
default boolean expired() {
return getBoolean("expired").booleanValue(false);
}
}
@Validation
restrictions can be declared in several places.
Validation that are always given for a specific Java type can be annotated on that type.
@Validation(type=STRING, pattern="[a-zA-Z0-9]{11}")
record ID(String id) {}
interface User extends JsonObject {
default ID getId() {
return getString("id").parsed(ID::new);
}
}
Or the annotation is placed on the property method or its return return type
interface User extends JsonObject {
@Required
default List<@Validation(maxLength=20) String> names() {
return getArray("names").stringValues();
}
}
Here names
is required and each name has a maxLength
of 20.
Important
When multiple annotations are affecting a property the restrictions declared on the method and return type override the restrictions declared on Java types. However, independent restrictions are merged. For example, using the ID
type limits type
to STRING and requires a certain pattern
to match. If a property using ID
is now annotated @Required
the required=YES
restriction is added on top of the restriction from the ID
Java type. If on the other hand ID
would also include required=YES
and the method gets annotated @Optional
the required=NO
from the @Optional
annotation overrides the required=YES
declared for ID
.
For complex JSON types ARRAY and OBJECT the items can be restricted independently from the container.
Either by annotating the container with @Items
or by using return type annotations.
interface User extends JsonObject {
@Items(@Validation(maxLength=20))
default List<String> names() { return getArray("names").stringValues(); }
}
is identical to
interface User extends JsonObject {
default List<@Validation(maxLength=20) String> names() { return getArray("names").stringValues(); }
}
The benefit of using the latter is that add validations to multiple levels
interface User extends JsonObject {
default List<@Validation(minItems=1) List<@Validation(uniqueItems=YES) Point> points() {
// ...
}
}
@Items
can also be used to create dedicated complex types, for example:
@Validation(type=ARRAY)
@Items(@Validation(type=INTEGER))
interface IntList extends JsonList<JsonInteger> { }
If no combination of the provided validations is sufficient users can declare their own using @Validator
.
The annotation refers to a record
class that implements the @Validation.Validator
interface.
record DateValidator() implements Validation.Validator {
public void validate( JsonMixed value, Consumer<Error> addError ) {
if (!value.isString()) return;
try {
LocalDate.parse(value.string());
} catch (Exception ex) {
addError.accept(Error.of(Rule.CUSTOM, value, "not a valid date: %s", value.string());
}
}
}
Important
Custom validators should always assume they are called with any type of JSON node depending on what was present in the actual input.
It is good practice to ignore types that are not suited for validation according to the value's semantic.
Any type mismatches should be caught by using @Validation(type=)
.
To use a custom validator simply annotate the property with @Validator
interface User extends JsonObject {
@Validator( DateValidator.class )
default String dateOfBirth() {
return getString("dateOfBirth").string();
}
}
Users can equally define their own validator meta-annotations
@Target( ElementType.METHOD )
@Retention( RetentionPolicy.RUNTIME )
@Validator( DateValidator.class )
public @interface Date {}
to reuse the same validation without having to link to the specific validator in all usage sites
interface User extends JsonObject {
@Date
default String dateOfBirth() {
return getString("dateOfBirth").string();
}
}
Dependent requires is used to declare properties as requires if certain conditions are met.
The general principle used here are named groups of properties.
A property can be added to a group by adding the groups name to the dependentRequired
list of group names.
The group names are picked by the user so that each is unique within the annotated type.
In the simplest form a group declares a set of codependent properties. That means if one of them is present all of them must be present.
interface User extends JsonObject {
@Validation(dependentRequired="name")
default String getFirstName() { return getString("firstName").string(); }
@Validation(dependentRequired="name")
default String getLastName() { return getString("lastName").string(); }
}
In the case of codependent properties they all have the role of a trigger and a dependent property at the same time. None of them is marked with a trigger condition, which will be explained in the following section.
In more advanced groups the member properties are split into roles of a trigger (with condition) and properties which are then required. Triggers are marked with their trigger condition, whereas those properties that are then required are not marked.
The common case is to trigger a group if a certain property is present.
It marks the group name with a !
suffix.
For example, when the first name is given, the last name must also be given:
interface User extends JsonObject {
@Validation(dependentRequired="name!")
default String getFirstName() { return getString("firstName").string(); }
@Validation(dependentRequired="name")
default String getLastName() { return getString("lastName").string(); }
}
The opposite is to trigger a group when a property is absent (undefined).
Such a trigger is marked with a ?
suffix.
For example, when no email
is given the oauth
is required.
interface User extends JsonObject {
@Validation(dependentRequired="login?")
String email() { return getString("email").string();
@Validation(dependentRequired="login")
String oauth() { return getString("oauth").string();
}
Triggers can also be based on the current string value of a property.
interface JsonPatch extends JsonObject {
@Validation(dependentRequired= {"add=add", "=move"})
String getOperation() { return getString("op").string();
@Validation(dependentRequired="add")
JsonMidex getValue() { return get("value", JsonMixed.class);
@Validation(dependentRequired="move")
String getFrom() { return getString("from").string();
}
A equals comparing trigger uses a suffix =
after the group name
followed by the value that triggers the group, e.g add=add
.
If group name and value are the same the group can be omitted, e.g. move
.
So the value
property is required when op
equals add
, and from
is required when op
equals move
.
A group may have more than one trigger property. In that case they all have to the satisfied to trigger. It is possible to mix present and absent and equals triggers in a group.
Lastly a dependent required property can be marked as mutual exclusive using the ^
suffix.
For example, to require either an email or an oauth login:
interface User extends JsonObject {
@Validation(dependentRequired="login^")
String email() { return getString("email").string();
@Validation(dependentRequired="login^")
String oauth() { return getString("oauth").string();
}
The mutual exclusive marker can be mixed and combined with !
and ?
triggers.