Validieren mit zwei Unbekannten

„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.