Front End Validierung mit Spring Boot

Web Anwendungen mit Spring Boot sind sehr schnell erstellt. Das Frontend kann alle Daten, über die bereitgestellten REST Schnittstellen, abrufen und ändern. Damit die Schnittstelle nur korrekte Daten akzeptiert, werden die Austauschklassen (DTO) mit Validierungs Annotationen versehen.

@Data
public class AddressDto {
  @NotNull @NotBlank
  private String street;
  @NotNull @NotBlank
  private String number;
  @NotNull @Pattern(regexp = "\\d{5}")
  private String plz;
  @NotBlank
  private String city;
}

In unserem Beispiel sollen Straße und Hausnummer gesetzt sein und nicht nur aus Leerzeichen bestehen.. Im Falle der Postleitzahl, soll diese aus exakt fünf Ziffern bestehen. Die Stadt ist optional soll aber auch nicht nur aus Leerzeichen bestehen.

Leider zeigt sich hier ein kleines Manko dieses Ansatzes. Die Entitäten für Spring Data müssen annotiert werden, die DTO müssen annotiert werden und das Frontend muss auch die Wertebereiche der Eingabe kennen. Drei verschiedene Konfigurationen konsistent zu halten ist arbeitsintensiv und fehleranfällig.

An dieser Stelle können ganz unterschiedliche Ansätze verwendet werden, um eine mehr oder weniger Single-Source-Of-Truth Lösung zu entwickeln. Im Idealfall eine einzige Definition der Wertebereiche. In diesem Beitrag werden wir das Problem auf zwei Definitionen reduzieren.

Der einfachste Ansatz ist der Verzicht auf die Validierung der DTO. Dies ist mit dem Nachteil verbunden, dass bei Fehlern im Frontend, fehlerhafte Daten ins Backend fließen.

Ein anderer Ansatz ist das Bereitstellen einer Validierungs-Konfiguration für das Frontend und das Backend. Ohne die Verwendung des Validierungs-Framework, den Spring Boot bereitstellt, erscheint dieser Ansatz auch nicht sinnvoll.

Der hier vorgestellten Ansatz verbindet den Validierungs-Framework mit einer Konfiguration für das Frontend. Da die Wertebereiche der Attribute durch Annotationen festgelegt sind, können wir die Annotationen doch einfach auslesen, aufbereiten und als JSON Response an das Frontend senden.

Das Frontend erhält in unserem Beispiel eine Liste von FieldConstraint Informationen, mit denen die Eingabefelder im Formular initialisiert werden.

[ 
{ "type" : "String", "name" : "street", "mandatory" : true, "nonBlank" : true }, 
{ "type" : "String", "name" : "number", "mandatory" : true, "nonBlank" : true }, 
{ "type" : "String", "name" : "plz",  "pattern" : "\\d{5}", "mandatory" : true }, 
{ "type" : "String", "name" : "city", "nonBlank" : true } 
]

Um die Idee schnell umzusetzen, werden die FieldConstraints von der folgenden Klasse realisiert.

@Data
public class FieldConstraint {
  private String type;
  private String name;
  private String pattern;
  private boolean mandatory;
  private boolean nonBlank;
  private boolean nonEmpty;
  private boolean future;
  private boolean present;
  private boolean past;
  private Long min;
  private Long max;
}

Zum Setzen der spezifischen Werte verwenden wir ein funktionales Interface, das für seine Aufgabe die Annotation und das FieldConstraint übergeben bekommt.

@FunctionalInterface
public interface FieldConstraintModifier {
  public void modify(Annotation annotation, FieldConstraint constraint);

  default FieldConstraintModifier andThen(FieldConstraintModifier after) {
    Objects.requireNonNull(after);
    return (Annotation a, FieldConstraint c) -> {
      modify(a, c);
      after.modify(a, c);
    };
  }
}

Um mehrere Modifikationen nacheinander auszuführen ergänzen wir noch die Methode andThen.

Bevor die Verwendung der FieldConstraintModifier aufgeklärt wird, schauen wir auf das Herz der ganzen Lösung. Die Klasse FieldConstraintModifier implementiert eine Callback Klasse von Spring. Sie wird für jedes Feld aufgerufen, das unsere Klasse im untersuchten DTO findet.

Der Callback überprüft Jedd Annotation am Feld darauf, ob sie selber mit Constraint annotiert ist. Daran sind Annotationen des Bean Validation Frameworks zu erkennen. Findet sich eine solche Annotation, dann wird der dafür bereitgestellte FieldConstraintModifier aufgerufen und das FieldConstraint für dieses Feld angepasst.

private final class ContrainedFieldCallback implements FieldCallback {
    private List<FieldConstraint> fieldConstraints = new ArrayList<>();

    @Override
    public void doWith(Field field) throws IllegalArgumentException, IllegalAccessException {
        FieldConstraint constraint = new FieldConstraint();
        constraint.setName(field.getName());
        constraint.setType(field.getType().getSimpleName());
        boolean changed = false;
        for (Annotation a : AnnotationUtils.getAnnotations(field)) {
            if (AnnotationUtils.findAnnotation(a.getClass(), Constraint.class) != null) {
                FieldConstraintModifier fieldConstraintModifier = creator.get(a.annotationType());
                if (fieldConstraintModifier != null) {
                    changed = true;
                    fieldConstraintModifier.modify(a, constraint);
                }
            }
        }
        if (changed) {
            fieldConstraints.add(constraint);
        }
    }
}

Damit eine Annotation unterstützt werden kann, muss ein Modifier für sie registriert werden. Der Einfachheit halber, realisieren wir dies über eine Map.

public void addCreator(Class<? extends Annotation> annotation, FieldConstraintModifier fieldConstraintCreator) {
  creator.put(annotation, fieldConstraintCreator);
}

Das Bean Validation Framework und Hibernate liefern schon eine Reihe von Annotationen, von denen einige im folgenden Code registriert werden.

validation.addCreator(NotNull.class, (Annotation a, FieldConstraint c) -> c.setMandatory(true));
validation.addCreator(NotEmpty.class, (Annotation a, FieldConstraint c) -> c.setNonEmpty(true));
validation.addCreator(NotBlank.class, (Annotation a, FieldConstraint c) -> c.setNonBlank(true));
validation.addCreator(Positive.class, (Annotation a, FieldConstraint c) -> c.setMin(1L));
validation.addCreator(PositiveOrZero.class, (Annotation a, FieldConstraint c) -> c.setMin(0L));
validation.addCreator(Negative.class, (Annotation a, FieldConstraint c) -> c.setMax(-1L));
validation.addCreator(NegativeOrZero.class, (Annotation a, FieldConstraint c) -> c.setMax(0L));
validation.addCreator(Future.class, FUTURE.andThen(PRESENT));
validation.addCreator(FutureOrPresent.class, FUTURE.andThen(PRESENT));
validation.addCreator(Past.class, PAST);
validation.addCreator(PastOrPresent.class, PAST.andThen(PRESENT));
validation.addCreator(Min.class, (Annotation a, FieldConstraint c) -> c.setMin(((Min) a).value()));
validation.addCreator(Max.class, (Annotation a, FieldConstraint c) -> c.setMax(((Max) a).value()));
validation.addCreator(Pattern.class, (Annotation a, FieldConstraint c) -> c.setPattern(((Pattern) a).regexp()));

Damit steht ein erster Entwurf für eine Frontend Konfiguration aus dem Spring Boot Backend. Wie dieser Entwurf verfeinert und elegant in Spring Boot eingebettet werden kann, wir im Folgebeitrag erklärt.