Frontend Validierung mit Spring Boot (Teil 2)

Wenn die Wertebereiche der Eingaben im Frontend geprüft werden ist es gut, wenn die Wertebereiche von Frontend und Backend übereinstimmen ist es schön. Elegant wird es, wenn Frontend und Backend ihre Wertebereiche aus der gleichen Quelle erhalten.

Im Beitrag Frontend Validierung mit Spring Boot wurde die grundlegende Idee, die Validierungs-Annotationen der DTO Klassen auszulesen, vorgestellt. Außerdem wurde die Machbarkeit mit einer einfachen Implementierung demonstriert.

In diesem Beitrag wird der Ansatz in Spring Boot integriert und die Implementierung verbessert.

Es gibt dabei drei Schwerpunkte für die Integration, das Auffinden unserer DTO Klassen, die Realisierung eines allgemeinen Endpoint für unsere Konfigurationsdaten und die Ergänzung weiterer Details in der Konfiguration.

Wie schon häufiger in diesen Beiträgen zäunen wir das Pferd von hinten auf und beginnen mit den Ergänzungen an den Konfigurationsdaten. Die bisherige Lösung mit einer großen POJO verwerfen wir und nutzen einen Builder, der eine Map befüllt.

@JsonInclude(Include.NON_DEFAULT)
public final class ConstraintInfo {
  private final Map<String, Object> details;

  private ConstraintInfo(Builder builder) {
    Map<String, Object> content = new LinkedHashMap<>();
    content.putAll(builder.content);
    details = Collections.unmodifiableMap(content);
  }

  @JsonAnyGetter
  public Map<String, Object> getDetails() {
    return this.details;
  }

  public static class Builder {
    private final Map<String, Object> content;

    public Builder() {
      content = new LinkedHashMap<>();
    }

    public Builder withDetail(String key, Object value) {
      content.put(key, value);
      return this;
    }

    public Builder withDetails(Map<String, Object> details) {
      content.putAll(details);
      return this;
    }

    public void removeAll(String... keys) {
      for (String key : keys) {				 
        content.remove(key);
      }
    }

    public boolean isEmpty() {
      return content.isEmpty();
    }

    public ConstraintInfo build() {
      return new ConstraintInfo(this);
    }
  }
}

Durch die Map ist unser Ansatz auch flexibler für die Erweiterung um eigene Validierungs-Annotationen.

Die ehemaligen Modifier, die nun AnnotationMapper heißen, müssen der Änderung selbstverständlich angepasst werden. Sie arbeiten nun mit dem neuen ConstraintInfo.Builder und können diverse zusätzliche Attribute aufnehmen. Dadurch können u.a. die Hibernate Annotationen EAN und ISBN besser unterstützt werden.

validation.addAnnotationMapper(ISBN.class, 
  (Annotation a, ConstraintInfo.Builder c) -> c.withDetail("format", "ISBN").withDetails(getAnnotationAttributes(a)));	

Auch die Validations Klasse profitiert von den obigen Änderungen. Die ConstraintInfo Instanz weiß über ihre Änderungen bescheid, so dass kein Flag mehr benötigt wird. Auch entfernen wir den generischen Typ und übergeben, statt einer Instanz, direkt die zu untersuchende Klasse. In den DTO Klassen können auch Listen von Objekten definiert sein. In diesem Fall soll neben dem Type List auch noch die Klasse der Objekte in der Liste ausgegeben werden.

public void doWith(Field field) throws IllegalArgumentException, IllegalAccessException {
  ConstraintInfo.Builder builder = new ConstraintInfo.Builder();
  for (Annotation a : AnnotationUtils.getAnnotations(field)) {
    if (AnnotationUtils.findAnnotation(a.getClass(), Constraint.class) != null) {
      creator.getOrDefault(a.annotationType(), AnnotationMappper.noop()).modify(a, builder);
    }
  }
  builder.removeAll("payload", "message", "groups");
  if (builder.isEmpty()) {
    return;
  }
  builder.withDetail("name", field.getName());
  builder.withDetail("type", field.getType().getSimpleName());

  if (field.getType().isAssignableFrom(List.class)) {
    Type type = ((ParameterizedType) field.getGenericType()).getActualTypeArguments()[0];
    builder.withDetail("component", ((Class<?>) type).getSimpleName());
  }
  fieldConstraints.add(builder.build());
}

Um eigene Annotationen zu unterstützen benötigt die Validations Klasse eine Methode um diese anzumelden. Darüber hinaus fügen wir eine addFeature Methode hinzu. Hinter dem AnnotationFeature Interface verstecken sich Klassen, die ein Set von AnnotationMapper der Validations Instanz hinzufügen. So können wir die Mapper für Hibernate Annotationen in die Klasse HibernateFeature und die für Bean Validation Annotationen in die Klasse ValidationFeature auslagern.

public void addAnnotationMapper(AnnotationMapper mapper) {  
  annotationsMapper.put(mapper.getName(), mapper);
}

public void addFeature(AnnotationFeature annotations) {
  annotations.attach(this);
}

Als nächstes müssen wir unsere Annotationen finden. Im Spring Boot Umfeld gibt es dazu viele Möglichkeiten. Wir verwenden in diesem Fall einmal einen ClassPathScanningCandidateComponentProvider. Mit dieser Komponente können wir alle Klassen unterhalb eines Base-Package finden, die mit einer speziellen Annotation versehen sind. In unserem Fall mit der eigenen Annotation @Frontend. Mit dieser Annotation wollen wir alle DTO Klassen versehen, die im Frontend überprüft werden sollen. Über die Annotation kann ein Name für die DTO in der Konfiguration definiert werden. Ansonsten wird der Basis-Name der Klasse verwendet.

Damit wir nicht unflexibel beim Spezifizieren des Base-Package sind, verwenden wir noch eine weitere Annotation @FrontendScan, mit der Base-Packages für unsere DTO Suche konfiguriert werden können.

Die Klasse FrontendScanPackages sucht dabei dir über @FronendScan definierten Packages zusammen. Sie ist eine Abwandlung einer Standard Klasse aus dem Spring Boot Universum, aber diverse Stellen wurden auf die Stream API umgestellt.

public class FrontendScanPackages {
  private static final String BEAN = FrontendScanPackages.class.getName();
  private static final String FRONTEND_SCAN_NAME = FrontendScan.class.getName();

  private static final FrontendScanPackages NONE = new FrontendScanPackages();

  private final List<String> packageNames;

  FrontendScanPackages(String... packageNames) {
    this.packageNames = Arrays.stream(packageNames).filter(StringUtils::hasText).collect(toList());
  }

  public List<String> getPackageNames() {
    return unmodifiableList(packageNames);
  }

  public static FrontendScanPackages get(BeanFactory beanFactory) {
    try {
      return beanFactory.getBean(BEAN, FrontendScanPackages.class);
    } catch (NoSuchBeanDefinitionException ex) {
      return NONE;
    }
  }

  public static void register(BeanDefinitionRegistry registry, String... packageNames) {
    if (registry.containsBeanDefinition(BEAN)) {
      BeanDefinition beanDefinition = registry.getBeanDefinition(BEAN);
      ConstructorArgumentValues constructorArguments = beanDefinition.getConstructorArgumentValues();
      String[] existing = (String[]) constructorArguments.getIndexedArgumentValue(0, String[].class).getValue();
      constructorArguments.addIndexedArgumentValue(0, concatenateStringArrays(existing, packageNames));
    } else {
      GenericBeanDefinition beanDefinition = new GenericBeanDefinition();
      beanDefinition.setBeanClass(FrontendScanPackages.class);
      beanDefinition.getConstructorArgumentValues().addIndexedArgumentValue(0, packageNames);
      beanDefinition.setRole(BeanDefinition.ROLE_INFRASTRUCTURE);
      registry.registerBeanDefinition(BEAN, beanDefinition);
    }
  }

    static class Registrar implements ImportBeanDefinitionRegistrar {
      @Override
      public void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefinitionRegistry registry) {
        register(registry, getPackagesToScan(metadata));
      }

      private String[] getPackagesToScan(AnnotationMetadata metadata) {
        AnnotationAttributes attributes = AnnotationAttributes		 
          .fromMap(metadata.getAnnotationAttributes(FRONTEND_SCAN_NAME));
        Class<?>[] basePackageClasses = attributes.getClassArray("basePackageClasses");
        if (basePackageClasses.length == 0) {
          return new String[] { ClassUtils.getPackageName(metadata.getClassName()) };
        }
        return Arrays.stream(basePackageClasses).map(ClassUtils::getPackageName).toArray(String[]::new);
      }
   }
}

Die Klasse FrontendScanner such dann mit Hilfe der gefundenen Packages, alle DTO Klassen zusammen, die mit @Frontend annotiert sind.

public class FrontendScanner {
  private final ApplicationContext context;

  public FrontendScanner(ApplicationContext context) {
    this.context = context;
  }

  public final Set<Class<?>> scan() throws ClassNotFoundException {
    List<String> packages = getPackages();
    if (packages.isEmpty()) {
      return Collections.emptySet();
    }
    ClassPathScanningCandidateComponentProvider scanner = new ClassPathScanningCandidateComponentProvider(false);
    scanner.setEnvironment(context.getEnvironment());
    scanner.setResourceLoader(context);
    scanner.addIncludeFilter(new AnnotationTypeFilter(Frontend.class));
    return packages.stream().filter(StringUtils::hasText).map(scanner::findCandidateComponents).flatMap(Set::stream)
				.map(this::mapToClass).filter(Objects::nonNull).collect(toSet());
  }

  private Class<?> mapToClass(BeanDefinition candidate) {
    try {
      return ClassUtils.forName(candidate.getBeanClassName(), context.getClassLoader());
    } catch (ClassNotFoundException | LinkageError e) {
      return null;
    }
  }

  private List<String> getPackages() {
    List<String> packages = FrontendScanPackages.get(context).getPackageNames();
    if (!packages.isEmpty()) {
      return packages;
    }
    return AutoConfigurationPackages.get(this.context);
  }
}

Als letzter Baustein für unsere Lösung benötigen wir nun noch einen REST Endpoint über den die Frontend Anwendung ihre Konfiguration ziehen kann. Unsere Lösung verwendet den Spring Boot Actuator, der Informationen über unseren Service bereitstellt.

Der Actuator ist den meisten Lesern über die Endpoints /actuator/health und /actuator/info bekannt. Der erste liefert aktuelle Informationen zum Zustand der REST-Anwendung und der zweite liefert statische Informationen, wie etwa den Name und die Version der Anwendung.

Unser Actuator soll unter dem Endpoint /actuator/frontend zu finden sein und damit eine Map aller DTO Konfigurationen liefern. Mit dem Endpoint /actuator/frontend/<dto> liefert unser Actuator die Konfigurationen eines einzelnen DTO.

@Component  @Endpoint(id = "frontend")
public class FrontendEndPoint {
  private static final Logger LOGGER = LoggerFactory.getLogger(FrontendEndPoint.class);

  private Map<String, List<ConstraintInfo>> infos = new HashMap<>();

  @Autowired
  public FrontendEndPoint(ApplicationContext applicationContext, List<AnnotationFeature> validationFeatures)
      throws ClassNotFoundException {
    createInfo(applicationContext, validationFeatures);
  }

  private void createInfo(ApplicationContext applicationContext, List<AnnotationFeature> validationFeatures)
    throws ClassNotFoundException {
    Validations validations = new Validations();
    validations.addFeature(new ValidationsFeature());
    validations.addFeature(new HibernateFeature());
    validationFeatures.stream().forEach(validations::addFeature);
    Set<Class<?>> frontendClasses = new FrontendScanner(applicationContext).scan();
    for (Class<?> frontendClass : frontendClasses) {
      LOGGER.info("frontend annotated class: {}", frontendClass);
      infos.put(createName(frontendClass), validations.read(frontendClass));
    }
  }

  private String createName(Class<?> frontendClass) {
    String name = AnnotationUtils.findAnnotation(frontendClass, Frontend.class).value();
    return name.isEmpty() ? frontendClass.getSimpleName() : name;
  }

  @ReadOperation
  public Map<String, List<ConstraintInfo>> frontend() {
    return infos;
  }

  @ReadOperation
  public List<ConstraintInfo> singleFrontend(@Selector String name) {
    return infos.get(name);
  }
}

Da die Informationen zur Konfiguration statisch sind, lesen wir sie nur einmal im Konstruktor ein.

Den FrontendActuator wird automatisch von der Spring Boot Anwendung erkannt und initialisiert. Wird nun der Actuator Endpoint /actuator/frontend aufgerufen, erhalten wir für unsere Beispiel DTO Klassen AddressDto, FacilityDto und OrganisationDTO folgende REST Antwort. Die Klasse OrganisationDTO ist mit @Frontend(„organisation“) annotiert, daher steht dort nicht der Klassennamen wie bei AddressDto.

{
  "organisaton": [
    { "mandatory": true, "name": "name", "type": "String" },
    { "format": "EMAIL", "flags": [], "regexp": ".*", "name": "email", "type": "String" }
  ],
  "AddressDto": [
    { "mandatory": true, "nonBlank": true, "name": "street", "type": "String" },
    { "mandatory": true, "nonBlank": true, "name": "number", "type": "String" },
    { "mandatory": true, "flags": [], "regexp": "\\d{5}", "pattern": {"empty":false }, "name": "plz", "type": "String" },
    { "nonBlank": true, "name": "city", "type": "String" },
    { "min": 1, "max": 4, "name": "tags", "type": "List", "component": "ZonedDateTime" }
  ]
}

Das Projekt Spring Boot Frontend zum Beitrag ist wieder auf GitLab zu finden. Wenn sich der Code zum Beitrag unterscheidet, dann liegt es daran, dass Software lebt und Texte nicht.