REST-basierte Schnittstellen mit dem Symfony-Framework implementieren: Web Services leicht gemacht
REST-basierte APIs spielen in den letzten Jahren eine immer wichtigere Rolle im Web 2.0. Mit Hilfe einer derartigen Schnittstelle können Anbieter von Webdiensten anderen Anwendungen Daten in kontrolliertem Umfang bereit stellen und sich auf diese Weise Zielgruppen jenseits der eigenen Website erschließen. APIs sind so zu einem sehr wichtigen Erfolgsfaktor geworden – man denke nur an Twitter oder Facebook und deren Ökosystem von Third-Party-Applikationen.
Mit Hilfe des Symfony-Frameworks [1] lassen sich derartige APIs auf Basis von REST [2] sehr effektiv umsetzen. Dies lässt sich am besten anhand eines beispielhaften Anwendungsfalls verdeutlichen: Ein fiktiver Buchhändler stellt eine Schnittstelle bereit, über den sich Informationen zum Angebot abfragen lassen und mit der Verleger mit Hilfe eines dedizierten Clients neue Bücher direkt in der Datenbank des Händlers speichern können.
Die Grundlagen von REST
Eine REST-basierte Schnittstelle besteht aus Ressourcen, die durch URIs adressierbar sind und in verschiedenen Repräsentationen dargestellt werden können. Ressourcen sind Informationen, die eigenständig referenziert werden sollen, beispielsweise Texte, ein Benutzerprofil oder eine Suchergebnisseite. Deren Repräsentation erfolgt meistens im XML-Format, da dieses Format die gewünschte Interoperabilität zwischen verschiedenen Systemen oder Plattformen noch am besten sicherstellen kann. Andere Formate wie JSON sind aber ebenfalls denkbar. Ressourcen lassen sich, wie auch im Web, miteinander verlinken.
Der Zugriff auf die API erfolgt ganz einfach durch das HTTP-Protokoll. Die verschiedenen Methoden des Protokolls codieren die Operationen auf der Anwendungsebene. Wird beispielsweise per GET auf eine Ressource zugegriffen, weiß der Server, dass ein Lesevorgang durchgeführt werden soll – derselbe Request mit der DELETE-Methode würde die Ressource hingegen löschen (mehr dazu unter [3] ).
Tools zum Testen von REST-APIs |
REST-basierte Schnittstellen lassen sich nicht direkt im Browser entwickeln oder testen, da sich die HTTP-Methoden für den Request nicht festlegen lassen und auch kein direkter Zugriff auf weitere Parameter des HTTP-Requests besteht. Abhilfe schafft das Programm „RESTClient“ (http://bit.ly/RESTClient) oder die Firefox-Erweiterung „RestTest“ (http://bit.ly/RESTTest). |
Die API in Symfony
Die Bestandteile einer REST-API lassen sich gut in Symfony abbilden: Module beziehungsweise deren Actioncontroller stellen die Ressourcen dar, die sich mit Hilfe des Symfony-Routings beliebig adressieren lassen. Dank des MVC-Patterns kann für jede gewünschte Repräsentation ein eigenes Template definiert werden.
Das Auslesen des Bücherbestands ist der wohl einfachste Anwendungsfall: Der Request an einen URI liefert eine Liste mit Büchern. Im Grunde unterscheidet sich dieser Anwendungsfall vom herkömmlichen Abruf im Browser nur durch die Repräsentationen der Response, die im XML- anstatt im HTML-Format dargestellt wird.
Dem Routing-System von Symfony kommt bei der Umsetzung einer REST-API eine wichtige Rolle zu. Durch die Routing-Regeln wird in erster Linie festgelegt, welchen URI eine Ressource bekommt, mit welcher HTTP-Methode darauf zugegriffen werden darf und welcher Actioncontroller den Request verarbeitet.
booklist: # Darstellung einer Liste mit dem Buchbestand url: /booklist.:sf_format class: sfRequestRoute param: { module: book, action: list, , sf_format: html } requirements: { sf_method: GET, sf_format: (?:xml|json|html) }
Listing 1
Im Zusammenhang mit REST-APIs spielt insbesondere der sf_format-Parameter eine wichtige Rolle. Durch ihn wird das Format bestimmt, in dem die Ressource an den Client ausgeliefert wird. Der Standardwert ist HTML, erlaubt sind in diesem Beispiel aber auch XML oder JSON. Alternativ lässt sich das Request-Format auch direkt in der Action festlegen (Listing 2), allerdings ist es eleganter, das direkt über den URI zu tun.
$request->setRequestFormat('xml');
Listing 2
Durch den sf_method-Parameter wird festgelegt, mit welchen Methoden eine Ressource aufgerufen werden kann. Dies kann besonders bei komplexeren APIs praktisch sein – zum Beispiel lässt sich so der Request für eine Ressource per DELETE-Methode unterbinden.
Für komplexere Requests mit mehr Parametern ist es darüber hinaus sogar möglich, bereits im Routing eine Validierung der GET-Parameter per regulärem Ausdruck vorzunehmen.
Die Bücherliste kann außer im HTML-Format auch als XML- oder JSON-Repräsentation angefordert werden. Dazu müssen für jedes Format eigene Templates angelegt werden und Symfony wählt auf Basis des gesetzten sf_request-Parameters das Passende aus. Ausschlaggebend ist hierbei die Dateiendung: listSuccess.php für eine standardmäßige HTML-Repräsentation, listSuccess.xml.php für XML und listSuccess.json.php für JSON.
Symfony liefert standardmäßig nur die HTML-Repräsentation mit einem Layout aus, das bei JSON oder XML natürlich entfällt. Außerdem kümmert sich das Framework selbstständig um den korrekten Content-Type (text/xml bzw. text/x-json) im HTTP-Header.
Komplexer: Datenmanipulation
Mit den beschriebenen Funktionen hat man im Prinzip bereits alles zusammen, was man braucht, um eine sehr einfache API zum Auslesen und Bereitstellen von Daten zu implementieren. Anspruchsvoller wird es jedoch, wenn die API nicht nur Daten auslesen, sondern auch ändern, löschen oder erstellen soll. Ein weitaus komplexeres Anwendungsszenario ist daher die Implementierung einer Schnittstellenfunktion, mit der ein Verleger neue Bücher direkt aus dem eigenen Warenwirtschaftssystem in das Shop-System übertragen kann. Aus einer „lesenden“ wird so eine „schreibende“ API.
Je umfangreicher eine API wird, desto sinnvoller kann es sein, diese in eine eigenständige Applikation auszulagern um eine saubere Trennnung zu den Front- und Backend-Applikationen zu haben, die in fast jedem Symfony-Projekt vorhanden sind.
Eine neue API-Applikation samt Book-Modul ist dank der Kommandozeilentools schnell angelegt:
$ symfony generate:app api $ symfony generate:module api book
Listing 3
Für das Book-Modul das Scaffolding zu verwenden, ist durchaus möglich, aber nicht unbedingt sinnvoll: Es werden dann zwar alle nötigen Methoden im Actioncontroller angelegt, diese gehen aber davon aus, dass die Daten zum Anlegen oder Ändern von Datensätzen von den ebenfalls generierten (HTML-)Formularen in den Templates stammen. Für die API allerdings müssen diese stattdessen aus den HTTP-Bodys extrahiert werden, wo sie im XML-Format an die Schnittstelle übertragen werden.
Bei einer dedizierten Applikation für die API kann auch die View-Konfiguration geändert werden (Listing 4): Ein Layout wird jetzt nicht mehr benötigt, da die Templates aus reinem XML bestehen. Außerdem wird auch der standardmäßige Content-Type angepasst.
default: http_metas: content-type: text/xml has_layout: off
Listing 4
Symfony und das Doctrine-ORM stellen für das Routing einen nützlichen Shortcut bereit:
book: class: sfDoctrineRouteCollection options: { model: Book }
Listing 5
Diese Regel wird zu einer Reihe REST-konformer Routen aufgelöst, ähnlich wie jener in Listing 1, die man sich in der Zusammenfassung auch durch die Kommandozeile anzeigen lassen kann:
$ symfony app:routes >> app Current routes for application Name Method Pattern book GET /book.:sf_format book_new GET /book/new.:sf_format book_create POST /book.:sf_format book_edit GET /book/:id/edit.:sf_for book_update PUT /book/:id.:sf_format book_delete DELETE /book/:id.:sf_format book_show GET /book/:id.:sf_format
Listing 6
Diese Routen verweisen auf die jeweiligen Methoden (new, create, edit, update usw.) im Actioncontroller des Book-Moduls, die später implementiert werden müssen.
Für den Einsatz innerhalb einer API sollten die „Edit“- und „New“-Methoden nicht implementiert werden beziehungsweise eine Fehlermeldung werfen, da eine GET-Methode niemals in der Lage sein sollte, Daten auf einem Server in irgendeiner Weise zu ändern (oder zu erstellen). Das ist ausschließlich den idempotenten Methoden PUT, POST und DELETE vorbehalten.
Authentifizierung durch API-Keys
Natürlich sollen nur autorisierte Benutzer Datensätze erstellen oder ändern dürfen. Bevor also eine Aktion ausgeführt wird, muss sich der Benutzer zunächst gegenüber der API authentifizieren. Eine Möglichkeit dazu ist die Verwendung von API-Keys. Der API-Key kann im HTTP-Header (zum Beispiel als „X-ApiKey“) mitgeschickt werden und dient als Ersatz für die übliche Benutzernamen/Passwort-Kombination (sinnvollerweise sollten Requests an die API in diesem Fall per HTTP/S gesichert werden). Die Authentifizierung kann in Symfony als Filter (Listing 7) implementiert werden, der vor dem Abarbeiten des eigentlichen Requests im Controller durchlaufen wird und in der filter.yml aktiviert werden muss.
class apiFilter extends sfFilter { public function execute($filterChain) { if ($this->isFirstCall()) { $request = $this->getContext()->getRequest(); $session = $this->getContext()->getUser(); // Get API-Key from HTTP-Header $apikey = $request->getHttpHeader('X-ApiKey'); // Select user from database by given API-Key $userprofile = Doctrine::getTable('User')->findOneByApikey($apikey); // Throw an error if user cannot be found... jeHTTPError::forwardUnless($userprofile, 401, 'Invalid or missing API-Key'); // ...or save the user in the session $session->setAttribute('user',$userprofile); } // Execute next filter in filter symfonys filter chain $filterChain->execute(); // Code to execute AFTER the action execution, before the rendering // Delete User-Session after every API-Request session_unset(); session_destroy(); } }
Listing 7
Dieser Filter lädt zum einen das Benutzerobjekt anhand des API-Keys im Header aus der Datenbank und speichert es in der Symfony-Benutzersession, sodass es überall im Code zur Verfügung steht. Ist der API-Key nicht korrekt, wird stattdessen eine Fehlermeldung mit entsprechendem HTTP-Statuscode gesendet. Zum anderen muss die Session wieder gelöscht werden, nachdem der Request abgearbeitet wurde. REST ist per Definition zustandslos, das heisst eine „klassische“ Session wird nicht gespeichert.
Für das Error-Handling kann wie in Listing 7 das jeHTTPErrorException-Plugin genutzt werden, welches das Symfony-Error Handling um die Fähigkeit erweitert, beliebige HTTP-Statuscodes und Fehlermeldungen in der Response zurückzugeben. Gerade im Zusammenhang mit REST ist das eine praktische Sache. Das Plugin lässt sich mit Hilfe des CLI schnell installieren:
$ symfony plugin:install jeHTTPErrorExceptionPlugin
Listing 8
Der Controller des Book-Moduls
In Listing 5 werden Routen auf die Index-, Show-, Create-, Update-, und Delete-Methoden des Book-Controllers erzeugt, die implementiert werden müssen. Die sfDoctrineRouteCollection-Klasse koppelt dabei ein Datenbankobjekt direkt mit dem ID-Parameter in der URL, sodass man im Controller darauf zugreifen und sich eigene Queries weitgehend sparen kann. Wird kein Datensatz mit der gewünschten ID gefunden, wirft Symfony einen 404-Fehler. Die Implementierung der Controller-Methoden geht daher schnell von der Hand. Um die Dateilansicht eines Buchs anzuzeigen, reicht eine Zeile im Controller:
// HTTP-Request: GET /book/[id] HTTP/1.1 public function executeShow(sfWebRequest $request) { $this->book = $this->getRoute()->getObject(); // $this->book is now available in the template }
Listing 9
Ein Request für denselben URI mit der DELETE-Methode wird REST-konform zur Delete-Methode geroutet:
// HTTP-Request: DELETE /book/[id] HTTP/1.1 public function executeDelete(sfWebRequest $request) { $book = $this->getRoute()->getObject(); // User allowed to delete this Book? If not, stop execution and send HTTP-Statuscode 403 $this->checkPermission($book); // ...User has Permission, let's delete this Book $book->delete(); }
Listing 10
Mit den POST- und PUT-Methoden wird ein neues Buch angelegt beziehungsweise ein bestehendes Buch geändert. Die dazu erforderlichen Daten werden als XML im HTTP-Body übertragen und müssen daher ausgelesen und geparst werden. Hierzu kann man im lib-Verzeichnis in einer Klasse eine einfache Methode (Listing 11) implementieren, die bei Gebrauch durch den Autoload-Mechanismus von Symfony inkludiert wird.
class Tools { public static function getRequestBody () { $stream = fopen("php://input", "r"); $data = stream_get_contents($stream); fclose($stream); return simplexml_load_string($data); } }
Listing 11
Ein neues Buch lässt sich ebenfalls sehr einfach erzeugen:
// HTTP-Request: POST /book HTTP/1.1 public function executeCreate(sfWebRequest $request) { $data = Tools::getRequestBody(); $book = new Book(); $book->saveBook($data); // Save Book in the Model $this->getResponse()->setStatusCode(201); $this->getResponse()->setHttpHeader('Location', $request->getHost().'/book/'.$book->get('id')); }
Listing 12
In dieser Methode werden die Daten zunächst aus dem Request-Body extrahiert und an das Model übergeben, wo das neue Buch dann angelegt wird. Wichtig ist, dem Client im Response per HTTP-Statuscode mitzuteilen, dass die Ressource erzeugt wurde („201 – Created“) und deren (neue) URI im Location-Header anzugeben.
Analog dazu funktioniert das Editieren eines Buchs:
// HTTP-Request: PUT /book/[id] HTTP/1.1 public function executeUpdate(sfWebRequest $request) { $data = Tools::getRequestBody(); $book = $this->getRoute()->getObject(); // User allowed to edit Book? If not, stop execution, send HTTP-Statuscode 403 $this->checkPermission($book); $book->saveBook($data); }
Listing 13
Für alle genannten Methoden müssen natürlich auch die jeweiligen (XML-)Templates angelegt werden, um einem Client das gewünschte Feedback – zum Beispiel in Form einer Statusmeldung – geben zu können.
Für die Validierung der Benutzerdaten bringt Symfony eine Reihe von Standard-Validatoren mit, die allerdings primär zur Verwendung mit dem Formular-Framework gedacht sind. Noch einfacher lassen sich alternativ die Validatoren des Doctrine-ORMs [4] verwenden, die man bereits in der Schema-Datei konfigurieren kann und die die Daten noch auf der Anwendungsebene unmittelbar vor dem Speichern in der Datenbank validieren.
Zusammenfassung
Mit Hilfe des Symfony-Frameworks lassen sich REST-basierte Schnittstellen sehr sauber und vergleichsweise einfach implementieren. Dieser Artikel zeigt nur einen von vielen Ansätzen, der sich aber grundsätzlich auch für umfangreiche APIs eignet – selbst wenn für den produktiven Einsatz noch einige Aspekte tiefer ausgearbeitet werden müssten.
Mit der Api-Key Lösung durch einen Filter entsteht das Problem, dass jeder vollen Zugriff hat, sobald z.B. aufgrund eines Fehlers ein Record erzeugt wurde der keinen Api-Key enthält.
Im Allgemeinen suche ich eine saubere Lösung für das Authentifizierungsproblem für Webservices in Symfony. Das wird im Jobeet-Tutorial geschickt übergangen, weil es in dem verwendeten Beispiel aufgrund des reinen Lesezugriffs nicht erforderlich ist. Um das Sessionbasierte Sicherheitssystem hinreichend anzupassen, bin ich jetzt seit Tagen damit beschäftigt, den kompletten Aufbau von Symfony zu analyisieren.
Dabei kommt mir immer wieder in den Sinn, dass es dafür bereits fertige saubere Lösungen geben müsste.
Vor allem denke ich, dass in puncto Sicherheit generell keine Workarounds angebracht sind.
Hallo fishbone,
glaube, so ganz verstehe ich Dein Problem nicht. Zugriffe mit ungültigen API-Keys werden mit einem Fehler 401 geblockt. Natürlich sollte man die Keys so aufbauen, dass sie nicht erraten werden können und eine SSL-gesicherte Verbindung verwenden.
Wenn ich Dich richtig verstanden habe, hast Du aber mit einem anderen Anwendungsfall Bauchschmerzen: Ein User könnte sich gegenüber der API mit einem korrekten Key authentifizieren und anschließend auf Datensätze zugreifen, die nicht von ihm selbst stammen. Habe ich das richtig verstanden? Das wäre allerdings keine Lücke, die man auf Ebene der API lösen kann – dasselbe Problem hätte man schließlich auch bei einer „herkömmlichen“ Authentifizierung.
Natürlich muss man auf Applikationsebene sicherstellen, das ein bereits authentifizierter User (egal ob er sich mit einem API-Key oder Name/Passwort-Kombination authentfiziert hat) auch nur auf seine eigenen Datensätze zugreifen kann – auch dann wenn die Datensätze fehlerhaft in der DB abgelegt wurden (was ja schon an der Stelle verhindert werden müßte).
Hilft Dir das weiter? Oder habe ich Dich missverstanden?
vg,
Daniel
Wie bereits gesagt, entsteht das Problem nur, falls das ApiKey-Feld in der Tabelle für einen Benutzer leer ist. Wenn kein Api-Key bei der Authentifizierung übergeben wird, stimmen die ApiKeys überein (übergebener und leerer in der Datenbank).
Es ist klar, dass dies ein grundlegender Fehler ist. Die Spalte Api-Key darf natürlich nicht leer oder NULL sein. Allerdings kann dies passieren, wenn man die Schnittstelle erst in einer späteren Version hinzufügt und bereits Benutzerdaten vorhanden sind. Bei der Migration kann durchaus ein Fehler entstehen, der ein leerbleibendes ApiKey-Feld zur Folge hat. Auch wenn das unwahrscheinlich ist, möglich ist es.
Deswegen leite ich in meinem sfFilter sofort zu 401 weiter, falls kein ApiKey übergeben worden ist. Ich weiß aber nicht mehr, wie ich das im Detail gelöst habe. Denn wenn sich jemand über Cookie authentifiziert, wird (korrekterweise) auch kein ApiKey übergeben.
Zum Thema saubere Lösung:
Schnittstellen sind kein Sonderfall. Trotzdem muss man sich mit dem Aufbau des Sicherheitssystem befassen, während man für die normale Authentifizierung auf die offiziellen Symfonyplugins zurückgreifen kann. Vielleicht hätte ich da von Symfony einfach mehr erwartet. Aber schon Methoden wie forward404If() zeigen, dass Symfony 1 nicht unbedingt für REST ausgelegt ist.
Ja, da hast Du recht – die Version 1.x von symfony ist noch nicht so 100%ig RESTfull wie das bspw. bei Rails der Fall ist. Ich kam leider noch nicht dazu, mich mit 2.x auseinander zu setzen, aber soweit ich bisher gelesen habe, wird hier sehr viel stärker auf eine RESTartige Architektur gesetzt. Ich würde mich wundern, wenn im Rahmen dessen nicht auch eine Authentifizierungslösung integriert wärde.
Ein leeres API-Key Feld darf aber natürlich nicht sein wenn man eine Schnittstelle implementiert, das ist klar. Ich bin in meinem Fall davon ausgegangen, dass es sich schon auf DB-Ebene um ein NOT NULL-Feld handelt, dass in jedem Fall befüllt sein muss – vielleicht hätte ich in dem Artikel noch darauf hinweisen sollen.
Auf jeden Fall danke ich für den Artikel, der mich bei diesem Projekt, das inzwischen abgeschlossen ist, sehr unterstützt hat.
Freut mich zu hören, Dankeschön :)