Teil 2: Exception Handling

Tutorial: REST API Exception Handling mit BeanValidation

Ulrich Cech

©Shutterstock/McIek

Im ersten Teil der Artikelserie haben wir uns um das Abfangen sämtlicher möglicher Exceptions gekümmert. Was wir als nächstes Umsetzen wollen, ist die Umwandlung der Exception in eine einheitliche API-Antwortstruktur mit ausreichend Informationen, damit der Benutzer mit den Fehlermeldungen auch etwas anfangen kann.

Momentan würde unser ExceptionInterceptor beispielsweise folgendes zurückliefern: „Internal Server Error“, „File not found“… Das ist natürlich nicht sonderlich hilfreich. Wir haben in den Mappern zwar schon wenigstens einen passenden HTTP-Statuscode (404 für NotFound, 415 für UnsupportedMediaType usw.), aber das reicht uns natürlich noch nicht.

Daher definieren wir uns unsere gewünschte API-Antwortstruktur. Wir wollen folgende Informationen bereitstellen:

  • HTTP-Status
  • fachlicher Fehlercode
  • fachliche Fehlermeldung (lokalisiert)

Das Fehlerobjekt könnte also folgendermaßen definiert werden (wir verwenden JAXB-Entitäten, denn dadurch sind wir flexibel und können XML und JSON je nach gewünschtem Format zurückliefern):

import javax.ws.rs.core.Response;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlRootElement;
import javax.xml.bind.annotation.XmlType;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import java.util.Locale;
import java.util.ResourceBundle;

@XmlRootElement
@XmlAccessorType(XmlAccessType.FIELD)
@XmlType(propOrder={"status", "errorCode", "message"})
public class APIErrorResponse {

    @XmlJavaTypeAdapter(ResponseStatusAdapter.class)
    private Response.Status status;
    private String errorCode;
    private String message;

    public APIErrorResponse() {}

    public APIErrorResponse(APIError apiError, Locale locale) {
        this.status = apiError.getStatus();
        this.errorCode = apiError.getErrorCode();
        this.message = getLocalizedMessage(apiError, locale);
    }

    public APIErrorResponse(Exception exception, Locale locale) {
        this.status = Response.Status.INTERNAL_SERVER_ERROR;
        this.errorCode = "0";
        this.message = exception.getLocalizedMessage();
    }

    public APIErrorResponse(Response.Status status,
                            String errorCode,
                            String messageKey,
                            Locale locale) {
        this.status = status;
        this.errorCode = errorCode;
        this.message = getLocalizedMessage(messageKey, locale);
    }


    public String getErrorCode() {
        return this.errorCode;
    }

    public Response.Status getStatus() {
        return status;
    }

    public String getMessage() {
        return this.message;
    }


    private String getLocalizedMessage(APIError apiError,
                                       Locale locale) {
        ResourceBundle resourceBundle =
                ResourceBundle.getBundle(apiError.getClass().getName(), locale);
        return resourceBundle.getString(apiError.getMessageKey());
    }

    private String getLocalizedMessage(String messageKey,
                                       Locale locale) {
        ResourceBundle resourceBundle =
                ResourceBundle.getBundle(getClass().getName(), locale);
        return resourceBundle.getString(messageKey);
    }

}

Anmerkung: Was hinter APIError steckt, wird gleich im Anschluss erklärt.

Der ResponseStatusAdapter macht nichts anders, als den javax.ws.rs.core.Response.Status bei der „Serialisierung der HTTP-Antwort“ in einen String zu verwandeln:

import javax.ws.rs.core.Response;
import javax.xml.bind.annotation.adapters.XmlAdapter;

public class ResponseStatusAdapter extends XmlAdapter<String, Response.Status> {

    @Override
    public String marshal(Response.Status status) throws Exception {
        return status.name();
    }

    @Override
    public Response.Status unmarshal(String statusAsString) throws Exception {
        return Response.Status.valueOf(statusAsString);
    }

}


Anmerkung: Für die getLocalizedMessage()-Methoden in der Klasse APIErrorResponse benötigen wir entsprechende Properties-Dateien. Dazu jedoch später mehr.

Als nächstes wollen wir uns überlegen, wie wir alle gefangenen Exceptions sowie Fehlersituationen, in denen wir selber Exceptions generieren müssen, in diese einheitliche Response-Struktur überführen. Außerdem soll der Code der Fachdomäne wenig von dieser technischen Struktur wissen müssen.

Wir wollen die ganzen technischen Fehlerinformationen und die Fehlermeldung idealerweise kapseln, dazu bietet sich ein ENUM geradezu an. Dieser könnte folgende Struktur haben:

import javax.ws.rs.core.Response;

public enum APIUserError {

    U10001(Response.Status.BAD_REQUEST, "no_valid_username"),

    U10002(Response.Status.NOT_FOUND, "username_not_found");


    private Response.Status status;

    private String messageKey;


    APIUserError(Response.Status status, String messageKey) {
        this.status = status;
        this.messageKey = messageKey;
    }

}

Wir könnten natürlich nun alle Fehler in diesem einen ENUM auflisten, was bei größeren Projekten mit zahlreichen fachlichen Fehlersituationen nicht pragmatisch wäre… Außerdem wollen wir diese „User-Fehlercodes“ in der User-Domäne verwenden und beispielsweise „Account-Fehlercodes“ in der Account-Domäne (s. Domain Driven Design).

Wichtig dabei ist, dass alle Error-ENUMs die gleiche Struktur besitzen, damit sie identisch verwendet werden können. Um dies zu erreichen, definieren wir ein Interface:

import javax.ws.rs.core.Response;

public interface APIError {

    Response.Status getStatus();

    String getErrorCode();

    String getMessageKey();

}

Dieses Interface lassen wir nun einfach unsere Fehler-ENUMs implementieren:

import javax.ws.rs.core.Response;

public enum APIUserError implements APIError {

    U10001(Response.Status.BAD_REQUEST, "no_valid_username"),

    U10002(Response.Status.NOT_FOUND, "username_not_found");


    private Response.Status status;

    private String messageKey;


    APIUserError(Response.Status status, String messageKey) {
        this.status = status;
        this.messageKey = messageKey;
    }


    @Override
    public Response.Status getStatus() {
        return this.status;
    }

    @Override
    public String getErrorCode() {
        return this.name();
    }

    @Override
    public String getMessageKey() {
        return this.messageKey;
    }
}


Diese konkreten Fehler-ENUMs können wir nun auch in den einzelnen Fachdomänen ablegen, also beispielsweise den APIUserError im User-Package, ein APIAccountError im Account-Package usw.

Kommen wir jetzt noch einmal zurück zu unseren Properties-Dateien für die lokalisierten Fehlermeldungen. Im konkreten Fall erstellen wir die beiden Properties-Dateien für die deutsche und englische Fehlermeldung (APIUserError_de.properties, APIUserError_en.properties usw.) im gleichen Package wie APIUserError. Der Dateiinhalt hat dann die bekannte key-value-Struktur:

no_valid_username=Der \u00fcbergebene Benutzername ist ung\u00fcltig
no_valid_username=The provided username is invalid

Wenn wir nun noch einmal in unsere APIErrorResponse-Klasse (s. oben) schauen, sehen wir den Trick:
Da alle Fehler-ENUMs das APIError-Interface implementieren, können diese nun gleichermaßen in den APIErrorResponse-Konstruktor übergeben werden. Der zusätzliche Local-Parameter (der beispielsweise aus einem ApplicationContext ermittelt wird) sorgt dafür, dass das „Message“-Feld mit der lokalisierten Fehlermeldung gefüllt wird.

Ein Puzzleteil fehlt uns noch. Wir wollen nicht jede konkrete Exception abfangen und separat behandelt, zudem müssen wir an geeigneter Stelle eigene Exceptions werfen.

Überlegen wir noch einmal, welche Situation auftreten können:

  1. Wir haben im Domänencode eine konkrete Fehlersituation („Benutzername wurde nicht gefunden“) und müssen dies dem Benutzer der API in der HTTP-Response mitteilen.
  2. Aus dem Code oder einer ThirdParty Library wird eine Exception geworfen.
  3. Wir haben eine „unklare“ Situation, meist ein technisches Problem wie eine IOException und wollen dem Benutzer zumindest eine ordentliche Fehlermeldung zurückliefern und keine HTML-Fehlerseite vom WebServer.

Um auf ein einheitliches Exception-Handling innerhalb unserer API zu kommen, brauchen wir auch eine einheitliche Exception-Klasse. Diese sollte die 3 Fehlersituationen behandeln können:

import javax.ws.rs.core.Response;
import java.util.Locale;

public class APIException extends RuntimeException {

    public static final String HTTP_HEADER_X_ERROR = "X-Error";
    public static final String HTTP_HEADER_X_ERROR_CODE = "X-Error-Code";


    private Response httpResponse;


    public APIException(APIError apiError, Locale locale) {
        httpResponse = createHttpResponse(new APIErrorResponse(apiError, locale));
    }

    public APIException(Exception exception, Locale locale) {
        httpResponse = createHttpResponse(new APIErrorResponse(exception, locale));
    }

    public APIException(Response.Status status,
                        String errorCode,
                        String messageKey,
                        Locale locale) {
        new APIErrorResponse(status, errorCode, messageKey, locale);
    }


    public Response getHttpResponse() {
        return httpResponse;
    }


    private static Response createHttpResponse(APIErrorResponse response) {
        return Response.status(response.getStatus()).entity(response)
                .header(HTTP_HEADER_X_ERROR, response.getMessage())
                .header(HTTP_HEADER_X_ERROR_CODE, response.getErrorCode()).build();
    }

}

Die wichtigen Punkte dieser APIException sind:

  • Wir erben von Runtime-Exception, damit wir uns nicht mit CheckException herumschlagen bzw. die APIException zwingend in Methodensignaturen angeben müssen.
  • Jeder Konstruktur der Klasse bildet jeweils eine der vorgenannten Fehlersituationen ab.

Nun müssen wir noch die Verarbeitung dieser Exception im ExceptionInterceptor erweitern:

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

@Interceptor
public class APIExceptionInterceptor {

    @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 {
                errorResponse = new APIException(ex, locale).getHttpResponse();
            }
            return errorResponse;
        }
        return proceedResponse;
    }
}

Anmerkung: Die java.util.Locale wird über einen Producer zur Verfügung gestellt und hier injiziert (siehe @Produces).

Im catch-Bereich wird nun geprüft, ob es sich um eine APIException handelt und falls ja, wird deren HttpResponse direkt zurückgeliefert. Außerdem kann der Fall eintreten, dass unsere APIException noch einmal „gewrappt“ wurde und sie sich im getCause() befindet. Tritt eine andere Exception auf, wird diese in eine APIException „umgewandelt“ (im zweiten else-Fall). Nehmen wir den Fehlerfall, dass wir in unserem Code einen fachliche Fehler generieren wollen („Benutzername wurde nicht gefunden“):

@Stateless
public class UserRepository {

    @PersistenceContext
    EntityManager entityManager;

    @Inject
    Locale locale;

    public UserRepository() {}

    public User getByUsername(String username) {
        User user = entityManager.find(User.class, username);
        if (user != null) {
            return user;
        }
        throw new APIException(APIUserError.U10002, locale);
    }

}

Wie man hier erkennt, ist es nun sehr einfach, eine Exception mit fachlichen und lokalisierten Meldungen zu erzeugen. Der Fachcode wird nicht mit technischen Aspekten gemischt (dieser Fachcode benötigt keinerlei Kenntnis, dass es sich um eine REST-Schnittstelle handelt und dass gewisse Felder einer bestimmten Error-Struktur gefüllt werden müssen). Diese Dinge sind in der APIException und dem APIError-ENUM versteckt und gekapselt.

Zwischenergebnis

Wir haben nun unser zentralisiertes Exception Handling aufgebaut. Um dieses zu erweitern, müssen nur die fachlichen Fehlermeldungen als APIError-ENUMs erzeugt und die Meldungen lokalisiert werden. Da sich die APIError-ENUMs auch in den entsprechenden fachlichen Packages befinden, bleiben wir bei der Implementierung in unserer jeweiligen Fachdomäne.

Morgen geht`s weiter: Im dritten Teil der Serie werden wir die Integration von BeanValidation angehen. Stay tuned!

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: