Eine einfache REST-API

Bevor PHP objektorientiert wurde, war es gang und gäbe, Anwendungen als eine Ansammlung von einzelnen PHP-Skripten zu erstellen. Gerne auch mit PHP, HTML und SQL gemischt in einer Datei. Die Dateinamen der Skripte entsprachen dann jeweils ihren URL-Pfaden, weil sie jeweils direkt vom Webserver aufgerufen wurden.

Heute wissen wir, dass sowas nicht gerade wartungsfreundlich ist und es darüber hinaus gar nicht so einfach ist, das einheitlich und sicher zu bekommen. Wir sind daher schon vor langer Zeit dazu übergegangen, für neue Anwendungen das sogenannte Tunnelling-Verfahren zu nutzen und mit der Ausbreitung von Frameworks ist es schnell zum Quasi-Standard geworden, alle Requests an PHP durch ein einzelnes Skript zu tunneln.

Normalerweise konfigurieren wir den Webserver dabei so, dass dieser zunächst versucht, vorhandene statische Inhalte wie CSS, JavaScript oder Bilder direkt auszuliefern, ohne dass PHP involviert ist. Es wäre ja auch aus Performance-Sicht nicht wirklich sinnvoll, ein PHP-Binary zu laden, nur um danach eine Datei direkt aus den Filesystem auszuliefern, ohne PHP überhaupt zu benutzen.

Die Einbindung von PHP in den Apache-Webserver funktionierte früher übrigens genau so: PHP wurde als Teil des Webserver-Prozesses immer geladen, unabhängig davon, ob ein statischer oder dynamischer Request verarbeitet wurde. Weil das nicht gerade ressourcenschonend war, verwenden wir heute normalerweise PHP-FPM, um die PHP-Prozesse vom Webserver-Prozess zu separieren. Anstelle von Apache kommt heute meist der schlankere und schnellere nginx als Webserver zum Einsatz.

Wenn unser Webserver nun einen HTTP-Request verarbeiten soll, der keine bereits vorhandene statische Datei abruft, dann wird diese Anfrage an das Tunnelling-Skript gesendet. Wir nennen es aus nostalgischen Gründen einfach mal index.php, wobei wir auch irgendeinen anderen Namen wählen könnten.

diagram

Die entsprechende Konfiguration dafür könnte für nginx etwa so aussehen:

location / {
    try_files $uri @php;
}

location @php {
    fastcgi_pass 127.0.0.1:9000;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME /project/index.php;
}

Bevor wir uns nun die Datei index.php selbst ansehen, schreiben wir uns erst einmal eine Klasse, welche unsere kleine REST-API repräsentiert. Wir wollen ja schließlich möglichst objektorientiert arbeiten:

<?php declare(strict_types=1);

class Rest
{
    private array $routes = [
        '/foo' => FooResource::class,
        '/bar' => BarResource::class
    ];

    public function run(): string
    {
        $class = $this->routes[$_SERVER['REQUEST_URI']];
        $resource = new $class;
        $method = strtolower($_SERVER['REQUEST_METHOD']);

        return $resource->$method();
    }
}
Rest.php

Das hier ist ein stark vereinfachtes Beispiel und definitiv kein Beispiel für guten Code. Bevor wir aber aufzählen, was man hier noch alles verbessern müsste, sprechen wir darüber, was hier eigentlich passiert - und warum.

Unsere REST-API unterstützt erst mal nur statische URLs, und wir haben zwei Ressourcen definiert, und zwar foo und bar. Wir wollen für beide Ressourcen erst mal nur GET und POST-Requests umsetzen, wir werden aber gleich sehen, dass es sehr einfach ist, auch noch weitere HTTP-Methoden umzusetzen.

Im Property routes definieren wir ein assoziatives Array, das dem URL-Pfad einer Ressource jeweils die Klasse zuordnet, welche diese Ressource repräsentiert und die an diese Ressource gerichteten HTTP-Requsts verarbeiten wird.

In der Methode run() schauen wir erst mal nach, welcher URL-Pfad aufgerufen wurde und holen uns den entsprechenden Klassennamen aus routes. Dann erzeugen wir ein Objekt dieser Klasse und ermitteln, welche Methode $method wir darin aufrufen wollen. Zu guter Letzt rufen wir diese Methode dann auch tatsächlich auf und geben das Ergebnis zurück.

Die Ressourcen sehen wie folgt aus:

<?php declare(strict_types=1);

class FooResource
{
    public function get(): string
    {
        return 'GET /the-foo-resource';
    }

    public function post(): string
    {
        return 'POST /the-foo-resource';
    }
}
FooResource.php
<?php declare(strict_types=1);

class BarResource
{
    public function get(): string
    {
        return 'GET /the-bar-resource';
    }

    public function post(): string
    {
        return 'POST /the-bar-resource';
    }
}
BarResource.php

Und so sieht unsere index.php aus:

<?php

require __DIR__ . '/autoload.php';

$_SERVER['REQUEST_URI'] = random_int(0, 1) === 0 ? '/foo' : '/bar';
$_SERVER['REQUEST_METHOD'] = random_int(0, 1) === 0 ? 'GET' : 'POST';

print (new Rest)->run();
index.php

Wir verwenden Autoload, wie es sich für modernen Code gehört. Dann würfeln wir, um unseren Tag ein wenig interessanter zu machen, aus, ob wir einen Request auf /foo oder /bar simulieren und ob das ein GET oder POST-Request sein soll. Normalerweise würde ich nichts in superglobale Variablen schreiben, aber für einen schnellen Test an der Kommandozeile kann man das schon mal so machen.

Probieren wir unsere REST-API aus:

POST /the-foo-resource
Ausgabe von index.php
index.php ausführen

Das dynamische Routing funktioniert. Ob wir nun Ressourcen, Controller, Handler oder was auch immer aufrufen: wir können in PHP sehr elegant zur Laufzeit sowohl Klassen- als auch Methodennamen zu ermitteln als auch Methoden aufrufen. Mehr oder weniger jedes Framework macht das so.

Wenn es nicht klappt

Diese Dynamik bezahlen wir natürlich damit, dass zur Laufzeit einiges schiefgehen kann. Wir könnten beispielsweise versuchen, eine nicht existierende Klasse zu instantiieren:

<?php declare(strict_types=1);

$class = 'doesNotExist';
$object = new $class;
noClass-will-fail.php
PHP Fatal error:  Uncaught Error: Class "doesNotExist" not found in noClass-will-fail.php:4
Stack trace:
#0 {main}
  thrown in noClass-will-fail.php on line 4
Ausgabe von noClass-will-fail.php
noClass-will-fail.php ausführen

Der Wert der Variable $class ist erst zur Laufzeit bekannt. Dadurch hat der Compiler keine Chance zu prüfen, ob wir eine vorhandene Klasse zu instantiieren. Deshalb führt der Versuch, Unsinn zu instantiieren, nicht zu einem Übersetzungsfehler, sondern zu einem Laufzeitfehler.

Das gleiche Problem haben wir nochmal mit den Namen der Methoden, die wir aufrufen:

<?php declare(strict_types=1);

$method = 'doesNotExist';
$object = new Something;
$object->$method();

class Something
{
}
noMethod-will-fail.php
PHP Fatal error:  Uncaught Error: Call to undefined method Something::doesNotExist() in noMethod-will-fail.php:5
Stack trace:
#0 {main}
  thrown in noMethod-will-fail.php on line 5
Ausgabe von noMethod-will-fail.php
noMethod-will-fail.php ausführen

Natürlich könnten wir sowohl die Existenz einer Klasse als auch einer Methode mittels Reflection prüfen, in PHP 7 sind allerdings die entsprechenden Laufzeitfehler zu Exceptions geworden, die wir einfach abfangen und entsprechend sinnvoll behandeln könnten.

Generell liegt es aber in der Natur der Programmiersprache PHP, eher Laufzeitfehler als Übersetzungsfehler zu produzieren. Darauf müssen wir mit einem geeigneten Error-Handling reagieren.

Wir sind noch nicht fertig

Es gibt einige Dinge, die wir in diesem Beispiel (zu) stark vereinfacht oder weggelassen haben:

In den einzelnen Ressourcen fehlt natürlich noch der ganze produktive Code, schließlich sollen unsere Ressourcen nicht nur vorgefertigte Texte zurückliefern. In der Realität würden wir die Ressourcen-Klassen vermutlich ähnlich wie MVC-Controller auf Services delegieren lassen.

Ausblick

Ein solches Tunneling-Skript nennt man übrigens auch Front Controller. Damit vermeiden wir Duplikation, weil wir das ganze Bootstrapping an einer zentralen Stelle durchführen. Wenn wir zusätzlich prüfen wollten, ob die Ressource überhaupt zugegriffen werden kann, wäre der Front Controller eine gute Stelle, das einzubauen. Die einzelnen Ressourcen müssten dann gar nichts von Zugriffsrechten wissen, was insbesondere bedeutet, dass man als Entwickler der API nicht den Fehler begehen kann, die Rechteprüfung für eine einzelne Ressource einfach zu vergessen oder nicht sauber zu implementieren.

Auf diese Weise lassen sich nicht alle Arten von Rechteprüfungen implementieren, aber das würde jetzt hier zu weit führen. Auch das Routing für eine Website sieht normalerweise anders aus, weil wir dort meistens GET-Requests verarbeiten.