RESTful: Anpassung von Fehlermeldungen in Lumen

In einem Projekt mit Microservice-Architektur haben wir das Format für die Fehlerausgabe unserer RESTful Services definiert. Clients soll, unabhängig vom genutzten Dienst, ein einheitliches Fehler-Interface geboten werden. In Anlehnung an die JSON API Spezifikation in Version 1.0 entstand dabei folgende vereinfachter Aufbau einer HTTP-Antwort:

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
	"errors": [
		{
			"message": "Error message",
			"source": { 
				"pointer": "/data/attributes/title"
			}
		},
		{ 
			"message": "Another error message"
			"source": { 
				"pointer": "/data/attributes/another-field"
			}
		}
	]
}

Wie bei REST üblich, gibt der HTTP-Status-Code ganz grundsätzlich Aufschluss über Erfolg oder Misserfolg bei der Verarbeitung der Anfrage. Im oberen Beispiel handelt es sich um einen Validierungsfehler, weil zwei Werte fehlerhaft übermittelt wurden.

Durch das Array errors haben wir die Möglichkeit, gleich ein Bündel an Fehlern zu übermitteln. So wird es beispielsweise für eine Single Page Application möglich, gleich alle Fehler im Formular anzuzeigen. Ansonsten müsste sich der Nutzer frustrierend von Fehler zu Fehler arbeiten, da ihm eine vollständige Liste all seiner Fehler verwehrt bleibt.

Die Eigenschaft source ist optional. Kann eine Ressource schlichtweg nicht gefunden werden, bedarf es dafür also keiner weiteren Angabe. In einer GUI ließen sich damit hingegen die fehlerhaften Felder kennzeichnen.

Der Exception Handler

Hinweis: Für einen RESTful Microservice ist Lumen (Version 5.3) natürlich prädestiniert. Mit wenigen Anpassungen sollten folgende Ausführungen allerdings auch auf Laravel anwendbar sein.

Lumen besitzt für die Ausnahmeverarbeitung die Datei src/app/Exceptions/Handler.php. Dieser Exception Handler bietet neben der Möglichkeit des für den Clients unsichtbaren Reports, z. B. in einen Log-Dienst, die Methode render. Darin ist es möglich die Fehlerausgabe zu implementieren.

<?php

[...]

class Handler extends ExceptionHandler
{
    [...]

    /**
     * Render an exception into an HTTP response.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Exception  $e
     * @return \Illuminate\Http\Response
     */
    public function render($request, Exception $e)
    {
    	if ($e instanceof CustomException) {
        	return response('Custom Message');
    	}
        return parent::render($request, $e);
    }
}

Grundsätzliche HTTP-Fehler

Zu Beginn des Lebenszyklus stehen grundsätzliche HTTP-Fehler, wie 404 Not found oder 403 Forbidden. Hier fußen Laravel und Lumen auf dem Symfony Fundament und kennen daher vor allem diese Ausnahme.

Symfony\Component\HttpKernel\Exception\HttpException

Für die Verarbeitung des häufig auftretenden Fehler 404, gibt es sogar eine eigene Exception-Klasse, die von der oberen erbt.

Symfony\Component\HttpKernel\Exception\NotFoundHttpException

Eine Implementierung für die Verarbeitung von HTTP-Fehlern könnte wie folgt aussehen.

<?php

[...]
use Symfony\Component\HttpKernel\Exception\HttpException;

class Handler extends ExceptionHandler
{
    [...]

    public function render($request, Exception $e)
    {
    	if ($e instanceof HttpException) {
		    $error = new \stdClass();
		    $error->message = ($e->getStatusCode() === 404) ? 'Not Found' : $e->getMessage();

		    return response()->json([
		    	'errors' => [
				    $error,
			    ]
		    ], $e->getStatusCode());
	    }
    }
}

Hinweis: Da die Methode getMessage() der NotFoundHttpException keine Fehlermeldung liefert, wird dieser selbst gesetzt.

Ruft man nun eine unbekannte Route auf, erhält man folgende Antwort:

HTTP/1.1 404 Not Found
Content-Type: application/json
{
  "errors": [
    {
      "message": "Not found"
    }
  ]
}

Wir erzeugen mittels folgender Route einen Fehler 403 und die damit verbundene Fehlermeldung:

$app->get('foo', function () {
	abort(403, 'Forbidden');
});
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
  "errors": [
    {
      "message": "Forbidden"
    }
  ]
}

Validierungsfehler

Sind die ersten HTTP-Hürden überwunden gelangt man im Lebenszyklus der Applikation recht schnell zur Validierung der übermittelten Werte. Die Controller Action könnte wie folgt aussehen:

<?php

[...]
  
class MyController extends Controller {
	public function create(Request $request) {
      	// Validate input
		$this->validate($request, [
		    'email'             => 'required|email|max:255',
		    'destinations'      => 'required|array',
		    'destinations.*'    => 'required|email|distinct',
	    ]);
      	
      	[...]
	}
}

Erwartet wird eine Anfrage mit folgendem Body:

{
    "email": "foo@example.com",
    "destinations": [
        "bar@example.com",
        "baz@example.com"
    ],
}

Wie der Lumen-Dokumentation zu entnehmen ist, wirft die Helferfunktion validate die Ausnahme Illuminate\Validation\ValidationException. Zurück in unserem Exception Handler, könnte wie folgt eine Verarbeitung dieser Ausnahmeart erfolgen.

<?php

[...]
use Illuminate\Validation\ValidationException;

class Handler extends ExceptionHandler
{
    [...]

    public function render($request, Exception $e)
    {
    	[...]
      
      	if ($e instanceof ValidationException) {
		    $validatorErrors = $e->validator->errors()->getMessages();
		    $errors = [];

			// Loop fields
		    foreach ($validatorErrors as $field => $error) {
		    	// Loop errors of field
				foreach ($error as $message) {
					$source = new \stdClass();
					$source->pointer = '/data/attributes/' . $field;

					$errors[] = [
						'message'   => $message,
						'source'    => $source,
					];
				}
		    }

		    return response()->json([
		    	'errors' => $errors,
		    ], 422);
	    }

        return parent::render($request, $e);
    }
}

Die zwei foreach-Schleifen kümmern sich um die Verarbeitung des mehrdimensionalen Arrays. Es kann nämlich sein, dass ein Feld mehrere Fehler hervorruft. Anschließend sieht der Body der HTTP-Antwort so aus.

HTTP/1.1 422 Forbidden
Content-Type: application/json
{
  "errors": [
    [
      {
        "message": "The destinations.0 must be a valid email address.",
        "source": {
          "pointer": "/data/attributes/destinations.0"
        }
      },
      {
        "message": "The email must be a valid email address.",
        "source": {
          "pointer": "/data/attributes/email"
        }
      }
    ]
  ]
}

Weitere Fehler

Die gängigen Fehler haben wir damit abgefrühstückt. Nun kann es allerdings immer noch sein, dass die gelieferten Daten nicht zur Verarbeitung der Anfrage verwendet werden können. Dies wird beispielsweise erst durch einen tieferen Blick in die Datenbank ersichtlich oder beim Kontakt mit einem weiteren Dienst, der für die Verarbeitung der Anfrage hinzugezogen wird. Derartige Fehler können wir nun auf die typische Laravel/Lumen Art erzeugen, indem wir die weitere Ausführung der Anwendung mittels abort Funktion abbrechen. Konkret sieht das dann so aus.

abort(409, 'Email forwarding already exists.');
HTTP/1.1 409 Conflict
Content-Type: application/json
{
  "errors": [
    {
      "message": "Email forwarding already exists."
    }
  ]
}

Patrick Baber

Als erfahrener Programmierer löst Patrick jeden Gordischen Knoten, findet die geeignete Methode und entwickelt damit flexible Software-Systeme.