Wie man ein zentralisiertes REST API Exception Handling mit BeanValidation kombiniert - Teil 3

REST API Exception Handling: Integration von BeanValidation

Ulrich Cech

©Shutterstock/McIek

In Teil 2 der Artikelserie haben wir die auftretenden Exceptions in eine einheitliche Antwortstruktur überführt. Jetzt kommen wir zum spannenden Kapitel: Der Integration von BeanValidation bzw. dabei auftretende Validierungsfehler und deren Überführung in unsere Fehlerstruktur.

In einer REST API benötigen wir immer eine gewisse Validierungen der übergebenen Werte. Dies kann die Prüfung auf Vorhandensein von Pflichtfeldern sein, oder aber auch eine Prüfung des Datenformates (sehr wichtig bei Datums-/Zeitformaten) oder die Format-Gültigkeit einer übergebenen eMail-Adresse.

Derartige Prüfungen sind jedoch meistens fachliche Prüfungen (bzw. werden durch fachliche Anforderungen definiert). Ein Geburtstdatum (in der Regel als java.util.Date) benötigt nur Datumsangaben, aber keine Zeitangaben. Ob ein Feld ein Pflichtfeld ist, wird in einer Datenstruktur ebenfalls durch fachliche Anforderungen definiert.

Diese Prüfungen möchte man daher ungern in einer technischen Schnittstellenschicht (hier die REST API) pflegen, sondern idealerweise direkt an den Domain-Objekten. Im idealen Fall bestehen diese Validierungen dort schon in Form von BeanValidation-Annotationen an den fachlichen Objekten.

Ein typisches Domain-Objekt könnte folgendermaßen aussehen:

import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;

@XmlRootElement
@XmlAccessorType(XmlAccessType.FIELD)
public class User {

        @NotNull
        @Size(min = 2, max = 40)
        private String username;

        @NotNull
        @Pattern(regexp = "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$")
        private String email;


    public UserDTO() {
    }

    // evtl. getter und setter
}

Dieses Objekt erwarten wir beispielsweise im HTTP-Request unserer UsersResource:

@POST
public Response registerUser(User userRequest) {
    User managedUser = userRepository.register(userRequest);
    URI uri = super.info.getAbsolutePathBuilder()
        .path("/" + managedUser.getEmail()).build();
    return Response.created(uri).entity(managedUser).build();
}

Wir haben nun zwei Möglichkeiten, die BeanValidation auf dem User-Objekt anzuwenden:

  1. @Valid-Annotation
  2. Programmatische BeanValidation

Die @javax.validation.Valid-Annotation wird einfach vor den Methoden-Parameter geschrieben. Dadurch wird automatisch nach der Deserialisierung des HTTP-Payloads in das User-Objekt die BeanValidation auf dem Objekt aufgerufen und die beiden Felder anhand der Annotationen validiert:

@POST
public Response registerUser(@Valid User userRequest) {
    // ...
}

Für den programmatischen BeanValidation-Aufruf benötigen wir etwas mehr Code:

import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;

public class ValidationController {

    // ...

    public static <T> void processBeanValidation(T entity) {
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        Validator validator = factory.getValidator();
        Set<ConstraintViolation<T>> errors = validator.validate(entity);
        if (!errors.isEmpty()) {
            throw new ConstraintViolationException(errors);
        }
    }

}

Und der Aufruf in unserer UsersResource wäre dann:

@POST
public Response registerUser(User userRequest) {
    // ...
    ValidationController.processBeanValidation(user);
}

Welchen Ansatz man hier konkret verfolgende möchte, das hängt von zahlreichen Faktoren ab. Bei der programmatischen Lösung ist man insgesamt flexibler, für einfache Modelle und einfache Use-Cases reicht die @Valid-Annotation meist schon aus.

Kommen wir nun zum eigentlich Spannenden: Wie kombinieren wir in dieses Konstrukt die BeanValidation mit unserem ExceptionHandling?

Dazu müssen wir ein wenig in die Tiefen der BeanValidation-Implementierung eintauchen. Dort finden wir, dass bei einer Verletzung der Regeln (NotNull, Size usw.) eine javax.validation.ConstraintViolationException erzeugt und geworfen wird.

Also können wir uns doch wieder eines ExceptionMappers bedienen:

import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.GenericEntity;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Request;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Variant;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;

@Provider
public class ConstraintViolationMapper
                        implements ExceptionMapper<ConstraintViolationException> {

    private static List<Variant> acceptableMediaTypes =
            Variant.mediaTypes(MediaType.APPLICATION_JSON_TYPE,
                               MediaType.APPLICATION_XML_TYPE).build();

    @Context
    protected Request request;


    @Override
    public Response toResponse(ConstraintViolationException ex) {
        Set<ConstraintViolation<?>> constViolations = ex.getConstraintViolations();
        List<ConstraintViolationEntry> errorList = new ArrayList<>();
        for (ConstraintViolation<?> constraintViolation : constViolations) {
            errorList.add(new ConstraintViolationEntry(constraintViolation));
        }
        GenericEntity<List<ConstraintViolationEntry>> entity =
                new GenericEntity<List<ConstraintViolationEntry>>(errorList) {};
        return Response.status(Response.Status.BAD_REQUEST)
                .entity(entity).type(getNegotiatedMediaType()).build();
    }

    protected MediaType getNegotiatedMediaType() {
        final Variant selectedMediaType = request.selectVariant(acceptableMediaTypes);
        if (selectedMediaType == null) {
            return MediaType.APPLICATION_JSON_TYPE;
        }
        return selectedMediaType.getMediaType();
    }

}

Anmerkung: Der Code um die Methode getNegotiatedMediaType() kann selbstverständlich noch ausgelagert werden, der Einfachheit halber habe ich ihn hier aufgeführt. Hierbei wird der ResponseType an den MediaType aus dem Request ermittelt. Das bedeutet, die Response-Struktur wird immer in dem Format zurückgeliefert, den der Client in seinem initialen Request mitgeliefert hat.

Eine ConstraintViolationException enthält immer alle aufgetretenen Validierungsfehler. Es werden also zuerst alle Validierungen durchgegangen und jede Verletzung wird dann in der Exception in einem Set gesammelt.

Nun wollen wir die einzelnen Validierungsverletzungen natürlich auch insgesamt dem Benutzer in einer Struktur zurückliefern. Deswegen wandeln wir die einzelnen ConstraintViolations in eine eigene Struktur um und liefern dann die komplette Liste als „entity“ in unserer HTTP-Antwort zurück:

import javax.validation.ConstraintViolation;
import javax.validation.Path;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlRootElement;
import java.util.Iterator;

@XmlRootElement
@XmlAccessorType(XmlAccessType.FIELD)
public class ConstraintViolationEntry {

    private String fieldName;
    private String wrongValue;
    private String errorMessage;

    public ConstraintViolationEntry() {}

    public ConstraintViolationEntry(ConstraintViolation violation) {
        Iterator<Path.Node> iterator = violation.getPropertyPath().iterator();
        Path.Node currentNode = iterator.next();
        String invalidValue = "";
        if (violation.getInvalidValue() != null) {
            invalidValue = violation.getInvalidValue().toString();
        }
        this.fieldName = currentNode.getName();
        this.wrongValue = invalidValue; 
        this.errorMessage = violation.getMessage();
    }

    public String getFieldName() {
        return fieldName;
    }

    public String getWrongValue() {
        return wrongValue;
    }

    public String getErrorMessage() {
        return errorMessage;
    }

}

Nun stören sich jedoch der ExceptionInterceptor und der ConstraintViolationMapper noch gegenseitig, oder anders ausgedrückt: Der ExceptionInterceptor greift in diesem Fall die Exception früher ab und behandelt sie, bevor sie der Mapper verarbeiten kann.

Aus diesem Grund müssen wir noch unseren ExceptionInterceptor etwas erweitern:

import javax.inject.Inject;
import javax.inject.Named;
import javax.interceptor.AroundInvoke;
import javax.interceptor.Interceptor;
import javax.interceptor.InvocationContext;
import javax.validation.ConstraintViolationException;
import javax.ws.rs.core.Response;
import java.util.Locale;

@Named
@Interceptor
public class RESTExceptionInterceptor {

    @Inject
    Locale locale;

    @AroundInvoke
    public Object handleException(InvocationContext context) {
        Object proceedResponse;
        try {
            proceedResponse = context.proceed();
        } catch (Exception ex) {
            Response errorResponse;
            if (ex instanceof APIException) {
                errorResponse = ((APIException) ex).getHttpResponse();
            } else if (ex.getCause() instanceof APIException) {
                errorResponse = ((APIException) ex.getCause()).getHttpResponse();
            } else if (ex.getCause() instanceof ConstraintViolationException) {
                throw (ConstraintViolationException) ex.getCause();
                // ---> this exception is handled via the ConstraintViolationMapper
            } else {
                errorResponse = new APIException(ex, locale).getHttpResponse();
            }
            return errorResponse;
        }
        return proceedResponse;
    }
}

Wichtig hierbei ist der zusätzliche „else if“-Zweig für die ConstraintViolationException. Wir extrahieren die ConstraintViolationException aus der gefangenen Exception als „cause“ und werfen sie aus dem Interceptor wieder hinaus. Dadurch kann dann unser ConstraintViolationMapper die ConstraintViolationException behandeln.

Zusammenfassung

Wir haben jetzt ein kleines Mini-Framework aufgebaut, mit dem wir die Behandlung unsere API Exceptions zentralisieren. Ein API Client bekommt immer eine einheitliche Fehlerstruktur zurück und die Fehlermeldung ist sogar lokalisiert. Außerdem werden Fehler, die von der BeanValidation erzeugt werden, ebenfalls automatisch in unsere zentrale Fehlerstruktur überführt, d.h. wir müssen für die BeanValidation nun keine separate Behandlung mehr implementieren und können uns wieder auf die Fachdomäne konzentrieren.

Der einzige Folgeaufwand besteht nur noch darin, die APIError-ENUMs zu erweitern bzw. für jede Fachdomäne zu erzeugen und die Fehlermeldung zu übersetzen. Soll dann eine Exception geworfen werden, bedienen wir uns der vorhandenen APIException und füllen diese mit dem passenden Fehler-ENUM.

Als möglicher Ausblick für weitere Optimierungen kann der ExceptionInterceptor über Interceptor-Bindung (http://docs.oracle.com/javaee/7/api/jav ax/interceptor/InterceptorBinding.html) anstelle der @Interceptor(s)-Annotation verwendet werden. Außerdem sollte man sich überlegen, ob man den Fehler-Code als echten Code (bspw. U10002) oder als lesbare Konstante (bspw. NO_USER_FOUND) verwenden möchte.

Aufmacherbild: Software developer programming code von Shutterstock / Urheberrecht: McIek

Verwandte Themen:

Geschrieben von
Ulrich Cech
Ulrich Cech
Ulrich Cech ist Senior Developer bei der dreamIT GmbH in Hamburg. Er verfügt über langjährige Erfahrung als Entwickler im Java-Enterprise-Bereich, insbesondere Java EE, und beschäftigt sich aktuell mit Java-EE-7-Migrationen. Sein besonderes Interesse liegt zusätzlich in leichtgewichtigen Server-Architekturen auf REST-Basis.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu: