“I want to be in a place of beginnings, not endings!”
Killing Joke (Unto the Ends of the Earth)
Mit Spring Boot können eigene REST Services schnell erstellt werden. Auch die dabei notwendige Validierung der Parameter ist mit dem Bean Validation Framework (JSR 380) in Windeseile hinzugefügt. Manchmal ist es aber notwendig, verschiedene Parameter gegeneinander zu prüfen. Dann reichen die Standardwerkzeuge nicht mehr aus und eigene Validatoren müssen erstellt werden. Anhand eines kleines Beispiel zeigt dieser Beitrag, wie einfach das geht.
Das folgende kurze Beispiel zeigt einen REST Endpunkt der einen neuen Ancestor
Eintrag in der Ahnenverwaltung erzeugt.
@PostMapping("/ancestor") @ResponseStatus(HttpStatus.CREATED) public AncestorModel create(@RequestBody @Valid AncestorDto ancestor) { return service.create(ancestor).map(ancestorModelAssembler::toModel).orElseThrow(); }
Die in der Anfrage übergebenen Daten sind mit der Annotation @Valid
markiert. Dadurch wird eine Validierung der AncestorDto
Instanz vorgenommen. Damit die Attribute der Instanz validiert werden können, benötigen sie eigene Annotationen. Im folgende eine vereinfachte Variante der Klasse AncestorDto
mit einigen Validierungs-Annotationen.
@Getter @Setter public class AncestorDto { @NotNull @Https @NotLocalhost private URI familyTree; @GedcomName private String name; private LocalDate birth; private LocalDate death; }
Das Attribut familyTree
ist mit der Validierungs-Annotationen @NoNull
, @Https
und @NotLocalhost
versehen. Die Standard Annotation @NotNull
prüft ob die URI gesetzt ist und die anderen beiden Annotationen aus dem Project URI-Validator prüfen, ob die URI
das Schema HTTPS und einen nicht auf localhost passenden Host besitzt.
Das Attribut name
ist mit einer eigens erstellen Annotation @GedcomName
versehen. Sie verwendet keine eigene ConstraintValidator
Implementierung, sondern nutzt die @Pattern
Annotation über Constraint Composition.
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE }) @Retention(RUNTIME) @Constraint(validatedBy = {}) @Pattern(regexp = "^([^/]*/[^/]+/[^/]*|[^/]+)$") public @interface GedcomName{ String message() default "{de.schegge.validator.GedcomName.message}"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
Das hier verwendete Pattern prüft, ob der Name maximal einen Nachnamen enthält, der in /
eingerahmt ist.
Die beiden anderen Attribute birth
und death
sind nicht annotiert, da sie im Kontext der Ahnenforschung optional sind. Zum einen sind nicht alle Personen in der Ahnendatenbank bereits verstorben und andererseits ist bei vielen Ahnen das Geburts- oder Sterbedatum schlicht unbekannt.
Obwohl beide Werte optional sind wäre eine Konsistenzprüfung praktisch. Es könnte ansonsten passieren, dass sich ein Sterbedatum einschleicht, das vor dem Geburtsdatum liegt.
Um die beiden Attribute mit einem eigenen ConstraintValidator
miteinander zu vergleichen bietet der JSR 380 drei Möglichkeiten.
Cross-Parameter Constraints zum Validieren der Parametern eines Methoden- oder Konstruktoraufrufs
Simple Constraints zum Validieren einzelnen Parametern und Attributen
Class-Level Constraints zum Validieren einer Klasseninstanz
Cross-Parameter Constraints
Die Cross-Parameter Constraints prüfen die Parameter eines Methoden- oder Konstruktoraufrufs. Für die hier vorgestellte Anwendung hilft dieser Ansatz nicht wirklich weiter. Die AncestorDto
Instanz ist beim Aufruf der Validierung schon vollständig erzeugt und außerdem der einzige Parameter der Methode. Dennoch ist hier einmal kurz skizziert, wein ein Cross-Parameter Constraints für eine Methode mit zwei LocalDate
Parametern ausschauen kann.
Die Annotation ist zügig erklärt. Sie besitzt Konstruktor und Methode als Ziel und wird mit dem ChronologialDateParametersValidator
geprüft.
@Target({ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.ANNOTATION_TYPE}) @Constraint(validatedBy = ChronologialDateParametersValidator.class) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface ChronologialDateParameters { String message() default "{de.schegge.validation.ChronologialDateParameters.message}"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
Der ChronologialDateParametersValidator
erweitert den ConstraintValidator
und validiert Object
Arrays, die der Parameterliste der annotierten Methoden und Konstruktoren entsprechen.
@SupportedValidationTarget(ValidationTarget.PARAMETERS) public class ChronologialDateParametersValidator implements ConstraintValidator<ChronologialDateParameters, Object[]> { @Override public boolean isValid(Object[] value, ConstraintValidatorContext context) { if (value.length != 2) { throw new IllegalArgumentException("Illegal method signature"); } if (value[0] == null || value[1] == null) { return true; } if (!(value[0] instanceof LocalDate) || !(value[1] instanceof LocalDate)) { throw new IllegalArgumentException("Illegal method signature, expected two parameters of type LocalDate."); } return isBeforeOrEquals((LocalDate) value[0], (LocalDate) value[1]); } private boolean isBeforeOrEquals(LocalDate begin, LocalDate end) { return begin.isEqual(end) || begin.isBefore(end); } }
In der isValid
Method wird geprüft, ob die übergebenen Parameter beide vom Type LocalDate
sind und der erste vor dem zweiten liegt. Wenn einer der beiden Parameter null
ist, dann muss nichts geprüft werden und die Validierung ist erfolgreich.
Die Prüfung der Parameter kann aber auch beliebig komplex betrieben werden. Beispielsweise wäre es möglich das die Methode mehr als zwei Parameter besitzt, dann müssen die beiden richtigen Parameter erst gesucht und ggf auch ihre Namen geprüft werden.
Simple Constraints
Obwohl die Simple Constraints nur einzelne Parameter oder Attribute prüfen, können sie hier über einen Trick genutzt werden. Die Annotation @AssertTrue
prüft boolesche Parameter und kann auch deren Getter annotieren. Im folgenden Beispiel ist die Methode isDeathAfterBirth
annotiert. Sie liefert nicht den Wert eines Parameters deathAfterBirth
sondern wertet die beiden Parameter death
und birth
aus. Da diese Methode innerhalb des DTO liegt wurde sie zusätzlich mit der Annotation @JsonIgnore
versehen. Auf diese Weise taucht kein Attribute deathAfterBirth
in der JSON Antwort auf.
@AssertTrue @JsonIgnore public boolean isDeathAfterBirth() { return death == null || birth == null || death.isEqual(birth) || death.isAfter(birth); }
Diese Lösung ist die einfachste Variante um zwei Attribute gegeneinander zu prüfen. Nachteilig ist hier nur, dass der Validierungscode innerhalb des DTO liegt.
Class-Level Constraints
Mit den Class-Level Constraints können Abhängigkeiten innerhalb von Klasseninstanzen geprüft werden. Auch hier wird eine eigene Annotation benötigt, die in diesem Fall die Klasse als Ziel besitzt.
@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE}) @Constraint(validatedBy = {ChronologialPeriodOfTimeValidator.class}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface ChronologialPeriodOfTime { String message() default "{de.vitroconnect.platform.partnerservice.rest.validation.ChronologialPeriodOfTime.message}"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
Diese mit dieser Annotation versehenen Klassen werden vom ChronologialPeriodOfTimeValidator
validiert. Damit dieser Validator wiederverwendet werden kann, validiert er Instanzen vom Typ PeriodOfTime
.
@ChronologialPeriodOfTime public interface PeriodOfTime { LocalDate getBegin(); LocalDate getEnd(); }
Durch die Annotation @ChronologialPeriodOfTime
an dem Interface PeriodOfTime
werden alle implementierenden Klassen automatisch validiert. Die Implementierung von ChronologialPeriodOfTimeValidator
ist den anderen beiden ConstraintValidator
Implementierungen ähnlich.
public class ChronologialPeriodOfTimeValidator implements ConstraintValidator<ChronologialPeriodOfTime, PeriodOfTime> { @Override public boolean isValid(PeriodOfTime period, ConstraintValidatorContext context) { return period == null || period.getBegin() == null || period.getEnd() == null || begin.isEqual(end) || begin.isBefore(end); } }
Nun muss nur noch die Klasse AncestorDto
angepasst werden und das Interface PeriodOfTime
implementieren.
@Getter @Setter public class AncestorDto implements PeriodOfTime { @GedcomName private String name; @NotLocalhost @Http @NotNull private URI familyTree; private LocalDate birth; private LocalDate death; }
Statt die Klasse AncestorDto
anzupassen können die beiden Attribute birth
und death
aber auch in eine eigene Klasse extrahiert werden. Im folgenden Beispiel existiert eine Klasse WeakPeriodOfTime
, die das Interface PeriodOfTime
implementiert.
@Getter @Setter public class AncestorDto { @GedcomName private String name; @NotLocalhost @Http @NotNull private URI familyTree; @Validate private WeakPeriodOfTime birthAndDeath; }
Damit das Attribute birthAndDeath
gegen den ChronologialPeriodOfTime
Constraint validiert wird, ist hier noch die Annotation @Validate
notwendig.
Wer eine schnelle und einfache Lösung für die Validierung mehrere Attribute sucht, ist mit der Annotation @AssertTrue
gut beraten. Wer etwas längerfristig plant, sollte einen eigenen Class-Level Constraint erstellen.